diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c86d00..aa5841b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [1.0.0] — 2025-03-30 +## [1.1.0] — 2026-03-30 + +### Fixed +- **Week analytics showing 0** — "Week" tab now uses a rolling last-7-days window instead of the current Mon–Sun calendar week; transactions are no longer excluded when added before this week's Monday +- **Light mode home page blank top area** — greeting + balance card section now has a distinct accent-gradient header, eliminating the visually empty pale zone at the top +- **Onboarding keyboard overlap** — name input page now uses `SingleChildScrollView` so the `TextField` is never obscured when the keyboard opens +- **Onboarding no back navigation** — back button added; appears on pages 2 and 3 so users can return to previous steps +- **Notification not firing on some devices** — integrated `flutter_timezone` to resolve the correct local timezone at runtime; notifications now fire at the right local time on all devices and regions +- **Notification delivery on aggressive battery-saving ROMs** (OnePlus, Xiaomi, etc.) — upgraded to `Importance.max` / `Priority.max`, added `fullScreenIntent`, `category: reminder`, and `visibility: public`; switched to `inexactAllowWhileIdle` for broader Android compatibility +- **Hive null crash on older records** — `tags` and `knownSenderIds` adapter casts changed to `List?` to handle records written before those fields existed + +### Added +- `USE_FULL_SCREEN_INTENT` Android permission for heads-up notification display +- `flutter_timezone` dependency for accurate timezone-aware scheduling + +--- + +## [1.0.0] — 2026-03-30 ### Added - Transaction tracking — add income, expenses, and transfers with categories, notes, and tags diff --git a/README.md b/README.md index f3a9ca4..6d86cfa 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Beautiful glassmorphism UI · Riverpod state · Hive local database · Zero data | Typography | Google Fonts — Plus Jakarta Sans | | Biometric Auth | `local_auth` | | SMS Reading | `flutter_sms_inbox` | -| Notifications | `flutter_local_notifications` | +| Notifications | `flutter_local_notifications`, `flutter_timezone` | | Background Tasks | `workmanager` | | File Operations | `path_provider`, `share_plus`, `file_picker` | @@ -137,6 +137,8 @@ lib/ - **Settings state** — `AppSettings` is cloned via `fromJson`/`toJson` on every update so Riverpod detects the change. Mutation-in-place won't work. - **Tab indices** in `HomeShell`: `0`=Dashboard, `1`=Transactions, `2`=FAB (opens sheet), `3`=Analytics, `4`=Settings. - **fl_chart v0.68** — does not accept `duration`/`curve` at the chart widget level. +- **Notifications** — `flutter_timezone` is used to resolve the device's local timezone at runtime via `FlutterTimezone.getLocalTimezone()`. This must be called during `NotificationService.init()` before any `zonedSchedule` call, otherwise notifications fire at UTC time on many devices. +- **Week analytics** — uses a rolling last-7-days window (`today − 6` to `today`), not a Mon–Sun calendar week, so data is always visible regardless of the day of the week. --- @@ -149,6 +151,7 @@ lib/ | `POST_NOTIFICATIONS` | Daily reminder notifications | | `CAMERA` / `READ_MEDIA_IMAGES` | Profile avatar picker | | `RECEIVE_BOOT_COMPLETED` | Reschedule notifications after reboot | +| `USE_FULL_SCREEN_INTENT` | Show heads-up notification display | All permissions are optional. Core tracking works without any of them. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ad681cc..e10a9bb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,8 +6,11 @@ + + + @@ -41,6 +44,23 @@ + + + + + + + + + + + + + Transaction Tracking
📊

Analytics & Charts

-

Bar charts, pie charts, and trend breakdowns for weekly, monthly, and yearly periods.

+

Bar charts, pie charts, and trend breakdowns. Week view shows a rolling last-7-days window so data is always visible.

🎯
@@ -584,6 +584,7 @@

Built with modern Flutter

local_auth
flutter_sms_inbox
flutter_local_notifications
+
flutter_timezone
Plus Jakarta Sans
diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart new file mode 100644 index 0000000..1022e83 --- /dev/null +++ b/lib/core/services/notification_service.dart @@ -0,0 +1,153 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:timezone/data/latest_all.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; +import 'package:flutter_timezone/flutter_timezone.dart'; +import '../constants/app_constants.dart'; +import '../utils/app_logger.dart'; + +class NotificationService { + NotificationService._(); + static final NotificationService instance = NotificationService._(); + + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + bool _initialized = false; + + // ── Initialise ────────────────────────────────────────────────────────────── + Future init() async { + if (_initialized) return; + try { + tz_data.initializeTimeZones(); + final timeZoneInfo = await FlutterTimezone.getLocalTimezone(); + tz.setLocalLocation(tz.getLocation(timeZoneInfo.toString())); + + const androidSettings = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const initSettings = InitializationSettings(android: androidSettings); + + await _plugin.initialize( + initSettings, + onDidReceiveNotificationResponse: (details) { + AppLogger.i('NotificationService', 'Notification tapped: ${details.payload}'); + }, + ); + + _initialized = true; + AppLogger.i('NotificationService', 'Initialized'); + } catch (e, stack) { + AppLogger.e('NotificationService', 'Failed to initialize', e, stack); + } + } + + // ── Request POST_NOTIFICATIONS permission (Android 13+) ──────────────────── + Future requestPermission() async { + try { + final status = await Permission.notification.request(); + AppLogger.i('NotificationService', 'Notification permission: $status'); + return status.isGranted; + } catch (e, stack) { + AppLogger.e('NotificationService', 'Permission request failed', e, stack); + return false; + } + } + + Future hasPermission() async { + return Permission.notification.isGranted; + } + + // ── Schedule daily reminder ───────────────────────────────────────────────── + Future scheduleDailyReminder({ + required int hour, + required int minute, + }) async { + if (!_initialized) await init(); + + try { + // Cancel existing before rescheduling + await _plugin.cancel(kDailyReminderNotifId); + + // Request permission if not granted + final granted = await hasPermission(); + if (!granted) { + final result = await requestPermission(); + if (!result) { + AppLogger.w('NotificationService', 'Notification permission denied — reminder not scheduled'); + return; + } + } + + final now = tz.TZDateTime.now(tz.local); + var scheduled = tz.TZDateTime( + tz.local, + now.year, + now.month, + now.day, + hour, + minute, + ); + + // If the time has already passed today, schedule for tomorrow + if (scheduled.isBefore(now)) { + scheduled = scheduled.add(const Duration(days: 1)); + } + + const androidDetails = AndroidNotificationDetails( + 'trackify_daily_reminder', + 'Daily Reminder', + channelDescription: 'Reminds you to log your daily expenses', + importance: Importance.max, + priority: Priority.max, + icon: '@mipmap/ic_launcher', + enableVibration: true, + playSound: true, + fullScreenIntent: true, + category: AndroidNotificationCategory.reminder, + visibility: NotificationVisibility.public, + ); + + const details = NotificationDetails(android: androidDetails); + + await _plugin.zonedSchedule( + kDailyReminderNotifId, + '💰 Time to log your expenses!', + 'Keep your finances on track — it only takes a minute.', + scheduled, + details, + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + matchDateTimeComponents: DateTimeComponents.time, // repeats daily + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + + AppLogger.i( + 'NotificationService', + 'Daily reminder scheduled at $hour:${minute.toString().padLeft(2, '0')} (next: $scheduled)', + ); + } catch (e, stack) { + AppLogger.e('NotificationService', 'Failed to schedule daily reminder', e, stack); + } + } + + // ── Cancel daily reminder ─────────────────────────────────────────────────── + Future cancelDailyReminder() async { + try { + await _plugin.cancel(kDailyReminderNotifId); + AppLogger.i('NotificationService', 'Daily reminder cancelled'); + } catch (e, stack) { + AppLogger.e('NotificationService', 'Failed to cancel reminder', e, stack); + } + } + + // ── Cancel all ────────────────────────────────────────────────────────────── + Future cancelAll() async { + try { + await _plugin.cancelAll(); + AppLogger.i('NotificationService', 'All notifications cancelled'); + } catch (e, stack) { + AppLogger.e('NotificationService', 'Failed to cancel all notifications', e, stack); + } + } +} diff --git a/lib/core/utils/app_logger.dart b/lib/core/utils/app_logger.dart new file mode 100644 index 0000000..4ee7030 --- /dev/null +++ b/lib/core/utils/app_logger.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +/// Lightweight structured logger. +/// In debug builds: prints all levels. +/// In release builds: only prints warnings and errors. +class AppLogger { + const AppLogger._(); + + static void i(String tag, String message) { + if (kDebugMode) { + debugPrint('[INFO] [$tag] $message'); + } + } + + static void w(String tag, String message) { + if (kDebugMode) { + debugPrint('[WARN] [$tag] $message'); + } + } + + static void e(String tag, String message, [Object? error, StackTrace? stack]) { + // Always print errors — even in release (visible in logcat/crash logs) + debugPrint('[ERROR] [$tag] $message'); + if (error != null) debugPrint('[ERROR] [$tag] $error'); + if (stack != null && kDebugMode) debugPrint('[ERROR] [$tag] $stack'); + } +} diff --git a/lib/data/models/app_settings.g.dart b/lib/data/models/app_settings.g.dart index fe4bded..2040eaa 100644 --- a/lib/data/models/app_settings.g.dart +++ b/lib/data/models/app_settings.g.dart @@ -26,7 +26,7 @@ class AppSettingsAdapter extends TypeAdapter { notificationHour: fields[6] as int, notificationMinute: fields[7] as int, smsReaderEnabled: fields[8] as bool, - knownSenderIds: (fields[9] as List).cast(), + knownSenderIds: (fields[9] as List?)?.cast(), autoBackupEnabled: fields[10] as bool, autoBackupHour: fields[11] as int, autoBackupMinute: fields[12] as int, diff --git a/lib/data/models/transaction_model.g.dart b/lib/data/models/transaction_model.g.dart index 2a7b2b3..8290a18 100644 --- a/lib/data/models/transaction_model.g.dart +++ b/lib/data/models/transaction_model.g.dart @@ -22,7 +22,7 @@ class TransactionModelAdapter extends TypeAdapter { type: fields[2] as TransactionType, categoryId: fields[3] as String, note: fields[4] as String, - tags: (fields[5] as List).cast(), + tags: (fields[5] as List?)?.cast(), date: fields[6] as DateTime, receiptImagePath: fields[7] as String?, isFromSms: fields[8] as bool, diff --git a/lib/features/analytics/analytics_screen.dart b/lib/features/analytics/analytics_screen.dart index 35715d1..1826c51 100644 --- a/lib/features/analytics/analytics_screen.dart +++ b/lib/features/analytics/analytics_screen.dart @@ -37,10 +37,11 @@ class _AnalyticsScreenState extends ConsumerState DateTimeRange _getRange() { final now = DateTime.now(); switch (_tabController.index) { - case 0: // Week - final start = now.subtract(Duration(days: now.weekday - 1)); + case 0: // Week — rolling last 7 days + final start = DateTime(now.year, now.month, now.day) + .subtract(const Duration(days: 6)); return DateTimeRange( - start: DateTime(start.year, start.month, start.day), + start: start, end: DateTime(now.year, now.month, now.day, 23, 59, 59), ); case 1: // Month @@ -248,7 +249,7 @@ class _AnalyticsScreenState extends ConsumerState const SizedBox(height: 16), if (_tabController.index == 0) _WeeklyBarChart( - transactions: allTransactions, + transactions: periodTxs, symbol: symbol, ) else @@ -682,25 +683,27 @@ class _WeeklyBarChart extends StatelessWidget { @override Widget build(BuildContext context) { final now = DateTime.now(); - final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); - final start = DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day); + final today = DateTime(now.year, now.month, now.day); - final weeklyData = {}; + // Index 0 = 6 days ago, index 6 = today + final dailyData = {}; for (int i = 0; i < 7; i++) { - weeklyData[i] = 0; + dailyData[i] = 0; } for (final t in transactions) { if (t.type == TransactionType.expense) { - if (t.date.isAfter(start.subtract(const Duration(seconds: 1))) && - t.date.isBefore(start.add(const Duration(days: 7)))) { - final int dayIndex = t.date.weekday - 1; - weeklyData[dayIndex] = (weeklyData[dayIndex] ?? 0) + t.amount; + final txDay = DateTime(t.date.year, t.date.month, t.date.day); + final daysAgo = today.difference(txDay).inDays; + if (daysAgo >= 0 && daysAgo < 7) { + final index = 6 - daysAgo; + dailyData[index] = (dailyData[index] ?? 0) + t.amount; } } } - final maxVal = weeklyData.values.fold(0.0, (m, v) => v > m ? v : m); + final maxVal = dailyData.values.fold(0.0, (m, v) => v > m ? v : m); final accent = Theme.of(context).colorScheme.primary; + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; return SizedBox( height: 160, @@ -730,9 +733,9 @@ class _WeeklyBarChart extends StatelessWidget { reservedSize: 28, getTitlesWidget: (v, _) { final idx = v.toInt(); - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final day = today.subtract(Duration(days: 6 - idx)); return Text( - days[idx], + dayNames[day.weekday - 1], style: GoogleFonts.plusJakartaSans( fontSize: 11, color: Theme.of(context) @@ -748,12 +751,12 @@ class _WeeklyBarChart extends StatelessWidget { gridData: const FlGridData(show: false), borderData: FlBorderData(show: false), barGroups: List.generate(7, (i) { - final isToday = i == (now.weekday - 1); + final isToday = i == 6; return BarChartGroupData( x: i, barRods: [ BarChartRodData( - toY: weeklyData[i] ?? 0.0, + toY: dailyData[i] ?? 0.0, width: 14, color: isToday ? accent : accent.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(4), diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart index 4d2aa0f..f1ffabe 100644 --- a/lib/features/dashboard/dashboard_screen.dart +++ b/lib/features/dashboard/dashboard_screen.dart @@ -54,94 +54,116 @@ class _DashboardScreenState extends ConsumerState { ? (monthlyExpense / settings.monthlyBudget!).clamp(0.0, 1.0) : 0.0; + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = Theme.of(context).colorScheme.primary; + return Scaffold( backgroundColor: Colors.transparent, body: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ + // Header section — greeting + balance card in a tinted container for light mode SliverToBoxAdapter( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${AppFormatters.formatGreeting()},', - style: GoogleFonts.plusJakartaSans( - fontSize: 14, - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), - ), - ), - Text( - settings.userName.isNotEmpty - ? '${settings.userName} 👋' - : 'Welcome 👋', - style: GoogleFonts.plusJakartaSans( - fontSize: 22, - fontWeight: FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], + child: Container( + decoration: isDark + ? null + : BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + accent.withValues(alpha: 0.13), + Theme.of(context).colorScheme.secondary.withValues(alpha: 0.07), + ], + ), + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(28), + ), ), - if (settings.avatarPath != null) - CircleAvatar( - radius: 22, - backgroundImage: AssetImage(settings.avatarPath!), - ) - else - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.secondary, + child: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${AppFormatters.formatGreeting()},', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + Text( + settings.userName.isNotEmpty + ? '${settings.userName} 👋' + : 'Welcome 👋', + style: GoogleFonts.plusJakartaSans( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ], ), - shape: BoxShape.circle, - ), - child: const Icon(Icons.person_rounded, - color: Colors.white, size: 22), + if (settings.avatarPath != null) + CircleAvatar( + radius: 22, + backgroundImage: AssetImage(settings.avatarPath!), + ) + else + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + accent, + Theme.of(context).colorScheme.secondary, + ], + ), + shape: BoxShape.circle, + ), + child: const Icon(Icons.person_rounded, + color: Colors.white, size: 22), + ), + ], + ), + ) + .animate(delay: 0.ms) + .fadeIn(duration: 400.ms) + .slideY(begin: -0.3, end: 0, duration: 400.ms, curve: Curves.easeOutExpo), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: _BalanceCard( + balance: balance, + income: monthlyIncome, + expense: monthlyExpense, + budgetRatio: budgetRatio, + currencySymbol: symbol, + monthlyBudget: settings.monthlyBudget, ), + ) + .animate(delay: 60.ms) + .fadeIn(duration: 500.ms) + .scale( + begin: const Offset(0.92, 0.92), + end: const Offset(1, 1), + duration: 500.ms, + curve: Curves.easeOutExpo, + ), ], ), ), - ) - .animate(delay: 0.ms) - .fadeIn(duration: 400.ms) - .slideY(begin: -0.3, end: 0, duration: 400.ms, curve: Curves.easeOutExpo), - ), - - // Balance card - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), - child: _BalanceCard( - balance: balance, - income: monthlyIncome, - expense: monthlyExpense, - budgetRatio: budgetRatio, - currencySymbol: symbol, - monthlyBudget: settings.monthlyBudget, - ), - ) - .animate(delay: 60.ms) - .fadeIn(duration: 500.ms) - .scale( - begin: const Offset(0.92, 0.92), - end: const Offset(1, 1), - duration: 500.ms, - curve: Curves.easeOutExpo, - ), + ), ), // Quick actions diff --git a/lib/features/onboarding/onboarding_screen.dart b/lib/features/onboarding/onboarding_screen.dart index c89598e..d27a862 100644 --- a/lib/features/onboarding/onboarding_screen.dart +++ b/lib/features/onboarding/onboarding_screen.dart @@ -42,6 +42,15 @@ class _OnboardingScreenState extends ConsumerState { } } + void _previousPage() { + if (_currentPage > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutExpo, + ); + } + } + Future _finish() async { final notifier = ref.read(settingsProvider.notifier); await notifier.setUserName(_nameController.text.trim()); @@ -71,18 +80,31 @@ class _OnboardingScreenState extends ConsumerState { child: SafeArea( child: Column( children: [ - // Skip button - Align( - alignment: Alignment.topRight, - child: TextButton( - onPressed: _finish, - child: Text( - 'Skip', - style: GoogleFonts.plusJakartaSans( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, + // Navigation row: back (when applicable) + skip + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (_currentPage > 0) + IconButton( + icon: const Icon(Icons.arrow_back_rounded), + color: Theme.of(context).colorScheme.primary, + onPressed: _previousPage, + ) + else + const SizedBox(width: 48), + TextButton( + onPressed: _finish, + child: Text( + 'Skip', + style: GoogleFonts.plusJakartaSans( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), ), - ), + ], ), ), // Page content @@ -167,10 +189,9 @@ class _WelcomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 80, @@ -227,6 +248,7 @@ class _WelcomePage extends StatelessWidget { const SizedBox(height: 12), TextField( controller: nameController, + textInputAction: TextInputAction.done, decoration: const InputDecoration( hintText: 'Enter your name', prefixIcon: Icon(Icons.person_outline_rounded), diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index e93accc..3198bb5 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -5,6 +5,8 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import 'package:local_auth/local_auth.dart'; import '../../core/constants/app_constants.dart'; +import '../../core/services/notification_service.dart'; +import '../../core/utils/app_logger.dart'; import '../../shared/providers/app_providers.dart'; class SettingsScreen extends ConsumerStatefulWidget { @@ -113,11 +115,7 @@ class _SettingsScreenState extends ConsumerState { title: 'Daily Reminder', subtitle: 'Get reminded to log expenses', value: settings.notificationEnabled, - onChanged: (v) => notifier.setNotificationSettings( - enabled: v, - hour: settings.notificationHour, - minute: settings.notificationMinute, - ), + onChanged: (v) => _toggleNotification(context, v), ), if (settings.notificationEnabled) ...[ _SettingsDivider(), @@ -445,6 +443,52 @@ class _SettingsScreenState extends ConsumerState { } } + Future _toggleNotification(BuildContext context, bool enable) async { + final settings = ref.read(settingsProvider); + if (enable) { + final granted = await NotificationService.instance.requestPermission(); + if (!granted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Notification permission denied. Please enable it in app settings.'), + behavior: SnackBarBehavior.floating, + ), + ); + } + return; + } + await ref.read(settingsProvider.notifier).setNotificationSettings( + enabled: true, + hour: settings.notificationHour, + minute: settings.notificationMinute, + ); + await NotificationService.instance.scheduleDailyReminder( + hour: settings.notificationHour, + minute: settings.notificationMinute, + ); + AppLogger.i('Settings', 'Daily reminder enabled at ${settings.notificationHour}:${settings.notificationMinute}'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Reminder set for ${settings.notificationHour.toString().padLeft(2, '0')}:${settings.notificationMinute.toString().padLeft(2, '0')} daily', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } else { + await ref.read(settingsProvider.notifier).setNotificationSettings( + enabled: false, + hour: settings.notificationHour, + minute: settings.notificationMinute, + ); + await NotificationService.instance.cancelDailyReminder(); + AppLogger.i('Settings', 'Daily reminder disabled'); + } + } + Future _pickTime(BuildContext context, int hour, int minute) async { final result = await showTimePicker( context: context, @@ -452,11 +496,29 @@ class _SettingsScreenState extends ConsumerState { ); if (result != null) { final settings = ref.read(settingsProvider); - ref.read(settingsProvider.notifier).setNotificationSettings( + await ref.read(settingsProvider.notifier).setNotificationSettings( enabled: settings.notificationEnabled, hour: result.hour, minute: result.minute, ); + // Reschedule with the new time if enabled + if (settings.notificationEnabled) { + await NotificationService.instance.scheduleDailyReminder( + hour: result.hour, + minute: result.minute, + ); + AppLogger.i('Settings', 'Reminder time updated to ${result.hour}:${result.minute}'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Reminder updated to ${result.hour.toString().padLeft(2, '0')}:${result.minute.toString().padLeft(2, '0')} daily', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } } } diff --git a/lib/features/transactions/add_transaction_screen.dart b/lib/features/transactions/add_transaction_screen.dart index dbadb47..ce84f80 100644 --- a/lib/features/transactions/add_transaction_screen.dart +++ b/lib/features/transactions/add_transaction_screen.dart @@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:uuid/uuid.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/utils/app_logger.dart'; import '../../data/models/transaction_model.dart'; import '../../data/models/category_model.dart'; import '../../shared/providers/app_providers.dart'; @@ -90,6 +91,7 @@ class _AddTransactionScreenState extends ConsumerState } Future _save() async { + // ── Input validation ────────────────────────────────────────────────── final amountText = _amountController.text.trim(); if (amountText.isEmpty) { _showError('Please enter an amount'); @@ -97,7 +99,7 @@ class _AddTransactionScreenState extends ConsumerState } final amount = double.tryParse(amountText); if (amount == null || amount <= 0) { - _showError('Please enter a valid amount'); + _showError('Please enter a valid amount greater than 0'); return; } if (_selectedCategoryId == null) { @@ -107,49 +109,80 @@ class _AddTransactionScreenState extends ConsumerState setState(() => _isSaving = true); _saveButtonController.forward(); - await Future.delayed(const Duration(milliseconds: 800)); - final tags = _tagsController.text - .split(',') - .map((t) => t.trim()) - .where((t) => t.isNotEmpty) - .toList(); + try { + await Future.delayed(const Duration(milliseconds: 400)); + + final tags = _tagsController.text + .split(',') + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) + .toList(); + + final ex = widget.existingTransaction; + final now = DateTime.now(); + final transaction = TransactionModel( + id: ex?.id ?? const Uuid().v4(), + amount: amount, + type: _selectedType, + categoryId: _selectedCategoryId!, + note: _noteController.text.trim(), + tags: tags, + date: _selectedDate, + receiptImagePath: _receiptImagePath, + isFromSms: ex?.isFromSms ?? false, + smsSource: ex?.smsSource, + createdAt: ex?.createdAt ?? now, + updatedAt: now, + ); - final ex = widget.existingTransaction; - final transaction = TransactionModel( - id: ex?.id ?? const Uuid().v4(), - amount: amount, - type: _selectedType, - categoryId: _selectedCategoryId!, - note: _noteController.text.trim(), - tags: tags, - date: _selectedDate, - receiptImagePath: _receiptImagePath, - isFromSms: false, - createdAt: ex?.createdAt ?? DateTime.now(), - updatedAt: DateTime.now(), - ); + AppLogger.i('AddTransaction', '${ex != null ? 'Updating' : 'Saving'} transaction id=${transaction.id} amount=${transaction.amount} type=${transaction.type.name} category=${transaction.categoryId}'); - if (ex != null) { - await ref.read(transactionProvider.notifier).update(transaction); - } else { - await ref.read(transactionProvider.notifier).add(transaction); - } + if (ex != null) { + await ref.read(transactionProvider.notifier).update(transaction); + } else { + await ref.read(transactionProvider.notifier).add(transaction); + } + + AppLogger.i('AddTransaction', 'Transaction saved successfully'); - HapticFeedback.mediumImpact(); - _saveButtonController.reverse(); - setState(() { - _isSaving = false; - _showSuccess = true; - }); - _successController.forward(); - await Future.delayed(const Duration(milliseconds: 800)); - if (mounted) Navigator.of(context).pop(true); + HapticFeedback.mediumImpact(); + _saveButtonController.reverse(); + if (!mounted) return; + setState(() { + _isSaving = false; + _showSuccess = true; + }); + _successController.forward(); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) Navigator.of(context).pop(true); + } catch (e, stack) { + AppLogger.e('AddTransaction', 'Failed to save transaction', e, stack); + _saveButtonController.reverse(); + if (!mounted) return; + setState(() => _isSaving = false); + _showError('Failed to save transaction. Please try again.\n${e.toString()}'); + } } void _showError(String msg) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(msg))); + if (!mounted) return; + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: Colors.white, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(msg, style: const TextStyle(fontSize: 13))), + ], + ), + backgroundColor: const Color(0xFFE8365D), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 4), + ), + ); } Future _pickDate() async { diff --git a/lib/main.dart b/lib/main.dart index a07c5ad..00c8971 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'core/theme/app_theme.dart'; +import 'core/services/notification_service.dart'; +import 'core/utils/app_logger.dart'; import 'data/models/transaction_model.dart'; import 'data/models/category_model.dart'; import 'data/models/bank_sms_message.dart'; @@ -73,6 +75,20 @@ void main() async { } } + // Initialize notification service + await NotificationService.instance.init(); + + // Reschedule daily reminder if it was previously enabled + final settingsBox = Hive.box(kSettingsBox); + final savedSettings = settingsBox.get('app_settings'); + if (savedSettings != null && savedSettings.notificationEnabled) { + AppLogger.i('main', 'Rescheduling daily reminder at ${savedSettings.notificationHour}:${savedSettings.notificationMinute}'); + await NotificationService.instance.scheduleDailyReminder( + hour: savedSettings.notificationHour, + minute: savedSettings.notificationMinute, + ); + } + runApp(const ProviderScope(child: TrackifyApp())); } diff --git a/lib/shared/providers/app_providers.dart b/lib/shared/providers/app_providers.dart index 4cb4b25..9d4c74d 100644 --- a/lib/shared/providers/app_providers.dart +++ b/lib/shared/providers/app_providers.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/utils/app_logger.dart'; import '../../data/repositories/transaction_repository.dart'; import '../../data/repositories/category_repository.dart'; import '../../data/repositories/settings_repository.dart'; @@ -38,9 +39,14 @@ class SettingsNotifier extends StateNotifier { SettingsNotifier(this._repo) : super(_repo.settings); Future update(void Function(AppSettings) updater) async { - await _repo.updateField(updater); - // Clone via JSON to get a new object instance so StateNotifier detects the change - state = AppSettings.fromJson(_repo.settings.toJson()); + try { + await _repo.updateField(updater); + // Clone via JSON to get a new object instance so StateNotifier detects the change + state = AppSettings.fromJson(_repo.settings.toJson()); + } catch (e, stack) { + AppLogger.e('SettingsNotifier', 'Failed to update settings', e, stack); + rethrow; + } } Future setThemeMode(int mode) async { @@ -135,18 +141,37 @@ class TransactionNotifier extends StateNotifier> { } Future add(TransactionModel tx) async { - await _repo.add(tx); - refresh(); + try { + AppLogger.i('TransactionNotifier', 'Adding transaction id=${tx.id}'); + await _repo.add(tx); + refresh(); + AppLogger.i('TransactionNotifier', 'Transaction added, total=${state.length}'); + } catch (e, stack) { + AppLogger.e('TransactionNotifier', 'Failed to add transaction id=${tx.id}', e, stack); + rethrow; + } } Future update(TransactionModel tx) async { - await _repo.update(tx); - refresh(); + try { + AppLogger.i('TransactionNotifier', 'Updating transaction id=${tx.id}'); + await _repo.update(tx); + refresh(); + } catch (e, stack) { + AppLogger.e('TransactionNotifier', 'Failed to update transaction id=${tx.id}', e, stack); + rethrow; + } } Future delete(String id) async { - await _repo.delete(id); - refresh(); + try { + AppLogger.i('TransactionNotifier', 'Deleting transaction id=$id'); + await _repo.delete(id); + refresh(); + } catch (e, stack) { + AppLogger.e('TransactionNotifier', 'Failed to delete transaction id=$id', e, stack); + rethrow; + } } List get thisMonth => _repo.getThisMonth(); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7299b5c..13807bb 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); + flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 786ff5c..b01d1fd 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + flutter_timezone url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d9b12df..00c9bd8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import file_selector_macos import flutter_local_notifications +import flutter_timezone import local_auth_darwin import share_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index a7ce8e2..f2a8717 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -395,6 +395,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index dd211e8..2815a66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: trackify description: A production-grade expense tracking app with glassmorphism UI. publish_to: 'none' -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: '>=3.0.0 <4.0.0' @@ -62,6 +62,7 @@ dependencies: # Sensors for parallax sensors_plus: ^5.0.1 + flutter_timezone: ^5.0.2 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3a39d19..3b6f07f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6165bed..9e0e260 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + flutter_timezone local_auth_windows permission_handler_windows share_plus