diff --git a/.github/workflows/check-version-on-pr.yml b/.github/workflows/check-version-on-pr.yml new file mode 100644 index 0000000..b3835bf --- /dev/null +++ b/.github/workflows/check-version-on-pr.yml @@ -0,0 +1,47 @@ +name: Check package version on PR to master + +on: + pull_request: + branches: + - master + types: [opened, synchronize, reopened] + +jobs: + check-version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get current app version from pubspec + id: pkg + run: | + version=$(grep '^version:' pubspec.yaml | head -n1 | sed -E 's/^version:[[:space:]]*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/') + echo "version=$version" >> $GITHUB_OUTPUT + + - name: Get latest tag + id: tag + run: | + latest_tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT + if [ -z "$latest_tag" ]; then + echo "Aucun tag trouvé, la vérification de version est ignorée." + fi + + - name: Compare app version with latest tag + run: | + pkg_version=${{ steps.pkg.outputs.version }} + latest_tag=${{ steps.tag.outputs.latest_tag }} + if [ -z "$latest_tag" ]; then + echo "Aucun tag de version trouvé, la vérification est ignorée." + exit 0 + fi + tag_version=${latest_tag#v} + if [ "$pkg_version" = "$tag_version" ]; then + echo "❌ La version du pubspec.yaml ($pkg_version) n'a pas été modifiée depuis le dernier tag ($latest_tag). Veuillez incrémenter la version avant de merger." + exit 1 + else + echo "✅ La version du pubspec.yaml ($pkg_version) est différente du dernier tag ($latest_tag)." + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f3fd331 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Create GitHub Release + +on: + push: + branches: + - master + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get app version from pubspec + id: vars + run: | + version=$(grep '^version:' pubspec.yaml | head -n1 | sed -E 's/^version:[[:space:]]*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/') + echo "version=$version" >> $GITHUB_OUTPUT + + - name: Format repository name + id: repo_name + run: | + repo="${GITHUB_REPOSITORY}" + # On enlève le propriétaire + repo_name="${repo#*/}" + # On remplace les underscores par des espaces + repo_name="${repo_name//_/ }" + echo "name=$repo_name" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.vars.outputs.version }} + name: "${{ steps.repo_name.outputs.name }} v${{ steps.vars.outputs.version }}" + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 482858b..9f6473d 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -106,32 +106,52 @@ class _HomePageState extends State { final pageItems = ops.skip((_page - 1) * _pageSize).take(_pageSize).toList(); // --- Monthly totals calculation (same banner as OperationsPage) --- + // Revenue: sliding 1-month window (from same day previous month until today). + // Expenses: keep calendar month (1st -> end of month). final now = DateTime.now(); final currentYear = now.year; final currentMonth = now.month; - double revenueCurrent = _operations - .where((o) => o.levyDate.year == currentYear && o.levyDate.month == currentMonth && o.amount > 0) + // compute start date as the same day last month; if that day doesn't exist + // in the previous month, use the last day of the previous month. + DateTime startOfSlidingMonth(DateTime ref) { + final int d = ref.day; + DateTime candidate = DateTime(ref.year, ref.month - 1, d); + // if overflow happened, DateTime will roll forward into the current month + if (candidate.month == ref.month) { + candidate = DateTime(ref.year, ref.month, 0); // last day of previous month + } + return candidate; + } + + final DateTime slidingStart = startOfSlidingMonth(now); + double revenueSliding = _operations + .where((o) => o.amount > 0) + .where((o) => (o.levyDate.isAfter(slidingStart) || o.levyDate.isAtSameMomentAs(slidingStart)) + && (o.levyDate.isBefore(now) || o.levyDate.isAtSameMomentAs(now))) .fold(0.0, (s, o) => s + o.amount); + // expenses stay in current calendar month double expenseCurrent = _operations .where((o) => o.levyDate.year == currentYear && o.levyDate.month == currentMonth && o.amount < 0) .fold(0.0, (s, o) => s + o.amount.abs()); + // If sliding window has no revenue, fallback to previous sliding window int displayRevenueYear = currentYear; int displayRevenueMonth = currentMonth; - double revenueToShow = revenueCurrent; - if (revenueCurrent == 0) { - if (currentMonth == 1) { - displayRevenueMonth = 12; - displayRevenueYear = currentYear - 1; - } else { - displayRevenueMonth = currentMonth - 1; - displayRevenueYear = currentYear; - } + double revenueToShow = revenueSliding; + if (revenueSliding == 0) { + final DateTime prevEnd = slidingStart; + final DateTime prevStart = startOfSlidingMonth(prevEnd); revenueToShow = _operations - .where((o) => o.levyDate.year == displayRevenueYear && o.levyDate.month == displayRevenueMonth && o.amount > 0) + .where((o) => o.amount > 0) + .where((o) => (o.levyDate.isAfter(prevStart) || o.levyDate.isAtSameMomentAs(prevStart)) + && (o.levyDate.isBefore(prevEnd) || o.levyDate.isAtSameMomentAs(prevEnd))) .fold(0.0, (s, o) => s + o.amount); + + // display label for the previous period (use end month of that window) + displayRevenueYear = prevEnd.year; + displayRevenueMonth = prevEnd.month; } String monthLabel(int y, int m) => DateFormat.yMMMM('fr_FR').format(DateTime(y, m)); diff --git a/lib/pages/operations_page.dart b/lib/pages/operations_page.dart index f7731fd..ebd291c 100644 --- a/lib/pages/operations_page.dart +++ b/lib/pages/operations_page.dart @@ -119,34 +119,46 @@ class _OperationsPageState extends State { Widget build(BuildContext context) { final ops = _filteredSorted; // --- Monthly totals calculation --- + // Revenue: sliding 1-month window (from same day previous month until today). + // Expenses: keep calendar month (1st -> end of month). final now = DateTime.now(); final currentYear = now.year; final currentMonth = now.month; - double revenueCurrent = _operations - .where((o) => o.levyDate.year == currentYear && o.levyDate.month == currentMonth && o.amount > 0) + DateTime startOfSlidingMonth(DateTime ref) { + final int d = ref.day; + DateTime candidate = DateTime(ref.year, ref.month - 1, d); + if (candidate.month == ref.month) { + candidate = DateTime(ref.year, ref.month, 0); + } + return candidate; + } + + final DateTime slidingStart = startOfSlidingMonth(now); + double revenueSliding = _operations + .where((o) => o.amount > 0) + .where((o) => (o.levyDate.isAfter(slidingStart) || o.levyDate.isAtSameMomentAs(slidingStart)) + && (o.levyDate.isBefore(now) || o.levyDate.isAtSameMomentAs(now))) .fold(0.0, (s, o) => s + o.amount); double expenseCurrent = _operations .where((o) => o.levyDate.year == currentYear && o.levyDate.month == currentMonth && o.amount < 0) .fold(0.0, (s, o) => s + o.amount.abs()); - // If current month has no revenues, display previous month revenues instead int displayRevenueYear = currentYear; int displayRevenueMonth = currentMonth; - double revenueToShow = revenueCurrent; - if (revenueCurrent == 0) { - // previous month - if (currentMonth == 1) { - displayRevenueMonth = 12; - displayRevenueYear = currentYear - 1; - } else { - displayRevenueMonth = currentMonth - 1; - displayRevenueYear = currentYear; - } + double revenueToShow = revenueSliding; + if (revenueSliding == 0) { + final DateTime prevEnd = slidingStart; + final DateTime prevStart = startOfSlidingMonth(prevEnd); revenueToShow = _operations - .where((o) => o.levyDate.year == displayRevenueYear && o.levyDate.month == displayRevenueMonth && o.amount > 0) + .where((o) => o.amount > 0) + .where((o) => (o.levyDate.isAfter(prevStart) || o.levyDate.isAtSameMomentAs(prevStart)) + && (o.levyDate.isBefore(prevEnd) || o.levyDate.isAtSameMomentAs(prevEnd))) .fold(0.0, (s, o) => s + o.amount); + + displayRevenueYear = prevEnd.year; + displayRevenueMonth = prevEnd.month; } String monthLabel(int y, int m) => DateFormat.yMMMM('fr_FR').format(DateTime(y, m)); diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index b580028..f3f1840 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -23,7 +23,7 @@ class _ProfilePageState extends State { int? id; bool? _isConnected; bool? _isVerifiedEmail; - DateTime? _lastLogin; + DateTime? _lastLogout; DateTime? _createdAt; DateTime? updatedAt; String? _appVersion; @@ -61,7 +61,7 @@ class _ProfilePageState extends State { _nameC.text = _name ?? ''; _isConnected = j['is_connected']; _isVerifiedEmail = j['is_verified_email']; - _lastLogin = j['last_login'] == null ? null : DateTime.tryParse(j['last_login']?.toString() ?? ''); + _lastLogout = j['last_logout_at'] == null ? null : DateTime.tryParse(j['last_logout_at']?.toString() ?? ''); _createdAt = j['created_at'] == null ? null : DateTime.tryParse(j['created_at']?.toString() ?? ''); updatedAt = j['updated_at'] == null ? null : DateTime.tryParse(j['updated_at']?.toString() ?? ''); _error = null; @@ -200,7 +200,7 @@ class _ProfilePageState extends State { ), // Account status summary (from API) - if (_isConnected != null || _isVerifiedEmail != null || _lastLogin != null || _createdAt != null) + if (_isConnected != null || _isVerifiedEmail != null || _createdAt != null) Card( elevation: 1, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -213,7 +213,6 @@ class _ProfilePageState extends State { if (_isConnected != null) const SizedBox(height: 6), if (_isVerifiedEmail != null) Row(children: [const Icon(Icons.email), const SizedBox(width: 8), Text('Email vérifié : ${_isVerifiedEmail! ? 'Oui' : 'Non'}'),]), if (_isVerifiedEmail != null) const SizedBox(height: 6), - if (_lastLogin != null) Row(children: [const Icon(Icons.access_time), const SizedBox(width: 8), Text('Dernière connexion : ${_lastLogin != null ? _lastLogin!.toLocal().toString() : ''}'),]), if (_createdAt != null) Row(children: [const Icon(Icons.calendar_today), const SizedBox(width: 8), Text('Créé le : ${_createdAt != null ? _createdAt!.toLocal().toString().split('.').first : ''}'),]), ]), ), diff --git a/lib/widgets/operation_dialogs.dart b/lib/widgets/operation_dialogs.dart index 2250334..3479991 100644 --- a/lib/widgets/operation_dialogs.dart +++ b/lib/widgets/operation_dialogs.dart @@ -571,8 +571,7 @@ class _OperationEditDialogState extends State { final body = { 'label': _nameC.text, - // store positive amount for subscription - 'amount': finalAmount.abs(), + 'amount': finalAmount, 'category': _categoryC.text, 'source': _sourceC.text.isEmpty ? null : _sourceC.text, 'destination': _destC.text.isEmpty ? null : _destC.text, diff --git a/lib/widgets/operations_table.dart b/lib/widgets/operations_table.dart index a0d8473..b15cc65 100644 --- a/lib/widgets/operations_table.dart +++ b/lib/widgets/operations_table.dart @@ -59,9 +59,22 @@ class OperationsTable extends StatelessWidget { case 'name': return DataCell(Text(o.label), onTap: () => onRowTap?.call(o)); case 'amount': - return DataCell(Text(o.amount.toStringAsFixed(2)), onTap: () => onRowTap?.call(o)); + Color? amountColor = o.amount > 0 ? Colors.green : (o.amount < 0 ? Colors.red : null); + return DataCell( + Text( + o.amount.toStringAsFixed(2), + style: amountColor != null ? TextStyle(color: amountColor) : null, + ), + onTap: () => onRowTap?.call(o), + ); case 'validated': - return DataCell(Icon(o.isValidate ? Icons.check_circle : Icons.remove_circle), onTap: () => onRowTap?.call(o)); + return DataCell( + Icon( + o.isValidate ? Icons.check_circle : Icons.remove_circle, + color: o.isValidate ? Colors.green : Colors.blue, + ), + onTap: () => onRowTap?.call(o), + ); case 'category': return DataCell(Text(o.category), onTap: () => onRowTap?.call(o)); case 'source': diff --git a/lib/widgets/subscriptions_table.dart b/lib/widgets/subscriptions_table.dart index ddf1843..9337623 100644 --- a/lib/widgets/subscriptions_table.dart +++ b/lib/widgets/subscriptions_table.dart @@ -44,9 +44,22 @@ class SubscriptionsTable extends StatelessWidget { case 'name': return DataCell(Text(s.label), onTap: () => onRowTap?.call(s)); case 'amount': - return DataCell(Text(s.amount.toStringAsFixed(2)), onTap: () => onRowTap?.call(s)); + Color? amountColor = s.amount > 0 ? Colors.green : (s.amount < 0 ? Colors.red : null); + return DataCell( + Text( + s.amount.toStringAsFixed(2), + style: amountColor != null ? TextStyle(color: amountColor) : null, + ), + onTap: () => onRowTap?.call(s), + ); case 'active': - return DataCell(Icon(s.active ? Icons.check_circle : Icons.remove_circle), onTap: () => onRowTap?.call(s)); + return DataCell( + Icon( + s.active ? Icons.check_circle : Icons.remove_circle, + color: s.active ? Colors.green : Colors.red, + ), + onTap: () => onRowTap?.call(s) + ); case 'category': return DataCell(Text(s.category), onTap: () => onRowTap?.call(s)); case 'interval': diff --git a/pubspec.yaml b/pubspec.yaml index 764d904..73b303e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.1+1 +version: 2.1.0+1 environment: sdk: '>=3.2.5 <4.0.0'