diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9cba988f..7553e158 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + + diff --git a/android/app/src/main/kotlin/org/pecha/app/MainActivity.kt b/android/app/src/main/kotlin/org/pecha/app/MainActivity.kt index 46489deb..e6f7df6e 100644 --- a/android/app/src/main/kotlin/org/pecha/app/MainActivity.kt +++ b/android/app/src/main/kotlin/org/pecha/app/MainActivity.kt @@ -1,17 +1,62 @@ package org.pecha.app import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings import com.ryanheise.audioservice.AudioServicePlugin import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + + companion object { + private const val NOTIFICATIONS_CHANNEL = "org.pecha.app/notifications" + } + override fun provideFlutterEngine(context: Context): FlutterEngine? { return AudioServicePlugin.getFlutterEngine(context) } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - // Plugin registration is handled automatically + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + NOTIFICATIONS_CHANNEL + ).setMethodCallHandler { call, result -> + when (call.method) { + "openChannelSettings" -> { + val channelId = call.argument("channelId") + if (channelId == null) { + result.error("ARG", "channelId required", null) + return@setMethodCallHandler + } + openChannelSettings(channelId) + result.success(null) + } + else -> result.notImplemented() + } + } + } + + /** + * Opens the OS notification settings page for a specific channel (Android 8+). + * Falls back to the app-level notification settings on older devices. + */ + private fun openChannelSettings(channelId: String) { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = android.net.Uri.fromParts("package", packageName, null) + } + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) } } diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml index c16fb74d..382c498c 100644 --- a/android/app/src/main/res/raw/keep.xml +++ b/android/app/src/main/res/raw/keep.xml @@ -1,3 +1,3 @@ + tools:keep="@drawable/ic_notification,@drawable/launch_background,@raw/routine" /> diff --git a/android/app/src/main/res/raw/routine.ogg b/android/app/src/main/res/raw/routine.ogg new file mode 100644 index 00000000..4fb5f4c5 Binary files /dev/null and b/android/app/src/main/res/raw/routine.ogg differ diff --git a/assets/audios/routine.caf b/assets/audios/routine.caf new file mode 100644 index 00000000..649111d3 Binary files /dev/null and b/assets/audios/routine.caf differ diff --git a/assets/audios/routine.ogg b/assets/audios/routine.ogg new file mode 100644 index 00000000..4fb5f4c5 Binary files /dev/null and b/assets/audios/routine.ogg differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8f5d570c..f1854a7b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -283,15 +283,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: - audio_service: cab6c1a0eaf01b5a35b567e11fa67d3cc1956910 - audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe + audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 Auth0: 2876d0c36857422eda9cb580a6cc896c7d14cb36 - auth0_flutter: 0f4846524696ef8441bcb96ceab7bfdbe31b8a05 - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + auth0_flutter: 031042ca6cf84f1b21611062b33ea7ec4bf3bc52 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 - firebase_analytics: 42693ebf35c4d330b74abcb46ca80351703644e0 - firebase_core: 98bcc1bd1a097bcb8b1ed6e091de3039802527c4 - firebase_crashlytics: 2fd6c030ca2f91e8d3b13d2e6e9a08a282c9d259 + firebase_analytics: 57b35da5be80006f5757f4e30d615d9d015007f6 + firebase_core: 9a760cbc5e2e92e2cddd82209e2e221049c7ba61 + firebase_crashlytics: 86206fa46d16696d4e74fcee696fb8104f59bf10 FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1 @@ -301,30 +301,30 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 - flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - image_gallery_saver_plus: 782ade975fe6a4600b53e7c1983e3a2979d1e9e5 - just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 + image_gallery_saver_plus: e597bf65a7846979417a3eae0763b71b6dfec6c3 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed JWTDecode: 7dae24cb9bf9b608eae61e5081029ec169bb5527 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SimpleKeychain: 768cf43ae778b1c21816e94dddf01bb8ee96a075 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a PODFILE CHECKSUM: 335c4a64b79e935971b4fb1168909818c4dbfb44 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0477f56b..cf63c7fb 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + AA1EECC0F56A2B1C3D4E5F61 /* routine.caf in Resources */ = {isa = PBXBuildFile; fileRef = AA1EECC0F56A2B1C3D4E5F60 /* routine.caf */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -86,6 +87,7 @@ E15311C3931A9D053F75AABB /* Pods-Runner.staging-release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.staging-release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.staging-release.xcconfig"; sourceTree = ""; }; E71115BFC7481D10DF061018 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; F8F7BCF3F147DEA424FE966C /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + AA1EECC0F56A2B1C3D4E5F60 /* routine.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = routine.caf; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -201,6 +203,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + AA1EECC0F56A2B1C3D4E5F60 /* routine.caf */, ); path = Runner; sourceTree = ""; @@ -308,6 +311,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 1FDD6EF63583D83501C608C9 /* GoogleService-Info.plist in Resources */, + AA1EECC0F56A2B1C3D4E5F61 /* routine.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/routine.caf b/ios/Runner/routine.caf new file mode 100644 index 00000000..649111d3 Binary files /dev/null and b/ios/Runner/routine.caf differ diff --git a/lib/core/config/router/app_router.dart b/lib/core/config/router/app_router.dart index ffc4edcb..9ed1bd2c 100644 --- a/lib/core/config/router/app_router.dart +++ b/lib/core/config/router/app_router.dart @@ -21,6 +21,7 @@ import 'package:flutter_pecha/features/practice/presentation/screens/edit_routin import 'package:flutter_pecha/features/practice/presentation/screens/practice_screen.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/select_plan_screen.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/select_recitation_screen.dart'; +import 'package:flutter_pecha/features/notifications/presentation/notification_settings_screen.dart'; import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; import 'package:flutter_pecha/features/reader/presentation/screens/reader_screen.dart'; import 'package:flutter_pecha/features/texts/presentation/screens/chapters/chapters_screen.dart'; @@ -277,6 +278,13 @@ final appRouterProvider = Provider((ref) { }, ), + // notifications route + GoRoute( + path: AppRoutes.notifications, + name: "notifications", + builder: (context, state) => const NotificationSettingsScreen(), + ), + // reader route - new refactored text reader GoRoute( path: "/reader/:textId", diff --git a/lib/core/config/router/app_routes.dart b/lib/core/config/router/app_routes.dart index 1a65c537..0b83f3fb 100644 --- a/lib/core/config/router/app_routes.dart +++ b/lib/core/config/router/app_routes.dart @@ -88,13 +88,13 @@ class AppRoutes { practice, // Guests can see empty practice screen practicePlanPreview, // Allow guests to browse/preview plans reader, + notifications, // Local-only — guests can configure routine notifications }; /// Base paths that require full authentication (prefix matching) static const Set _protectedBasePaths = { practiceEditRoutine, // Building routine requires auth profile, - notifications, plansInfo, recitationDetail, }; diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index ee141571..feeb2c06 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -70,11 +70,9 @@ class _HomeScreenState extends ConsumerState { if (!alreadyEnabled) { _log.info('Requesting notification permissions...'); final granted = await notificationService.requestPermission(); - if (granted) { - _log.info('Notification permissions granted'); - } else { - _log.info('Notification permissions denied'); - } + _log.info(granted + ? 'Notification permissions granted' + : 'Notification permissions denied'); } } catch (e) { _log.warning('Error requesting notification permissions: $e'); diff --git a/lib/features/more/presentation/more_screen.dart b/lib/features/more/presentation/more_screen.dart index 9f4488ba..cfd99e61 100644 --- a/lib/features/more/presentation/more_screen.dart +++ b/lib/features/more/presentation/more_screen.dart @@ -5,8 +5,10 @@ import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/theme/theme_notifier.dart'; import 'package:flutter_pecha/core/widgets/cached_network_image_widget.dart'; import 'package:flutter_pecha/features/auth/presentation/widgets/login_drawer.dart'; +import 'package:flutter_pecha/features/notifications/presentation/notification_settings_screen.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; +import 'package:go_router/go_router.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_pecha/core/constants/app_config.dart'; @@ -74,6 +76,17 @@ class MoreScreen extends ConsumerWidget { _buildLanguageRow(context, ref, locale), const SizedBox(height: 24), + // Notifications Section + _buildSectionHeader(context, localizations.notification_settings), + const SizedBox(height: 12), + _buildSettingsRow( + context, + icon: PhosphorIconsRegular.bell, + title: localizations.notification_settings, + onTap: () => context.push(NotificationSettingsScreen.routeName), + ), + const SizedBox(height: 24), + // Account Section _buildSectionHeader(context, localizations.settings_account), const SizedBox(height: 12), diff --git a/lib/features/notifications/data/channels/notification_channels.dart b/lib/features/notifications/data/channels/notification_channels.dart new file mode 100644 index 00000000..dd0cae41 --- /dev/null +++ b/lib/features/notifications/data/channels/notification_channels.dart @@ -0,0 +1,65 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Central registry for all app notification channels. +/// +/// To add a new channel (e.g. reminders, announcements): +/// 1. Add static const ID/name/description fields +/// 2. Add a static final AndroidNotificationChannel +/// 3. Add a static NotificationDetails factory method +/// No other file should define channel constants. +class NotificationChannels { + NotificationChannels._(); + + // ── Routine Block Reminder ────────────────────────────────────────────────── + static const String routineBlockId = 'routine_block_reminder'; + static const String routineBlockName = 'Routine Block Reminder'; + static const String routineBlockDescription = + 'Daily notifications for routine practice blocks'; + + /// Android raw resource sound — references android/app/src/main/res/raw/routine.ogg + /// Specified WITHOUT file extension, as required by Android. + static const RawResourceAndroidNotificationSound routineAndroidSound = + RawResourceAndroidNotificationSound('routine'); + + /// iOS sound file — routine.caf must be included in the Runner app bundle + /// (Runner target → Build Phases → Copy Bundle Resources). + static const String routineIosSoundFile = 'routine.caf'; + + /// Android notification channel for routine blocks. + /// Sound is baked in at channel creation time — Android does not allow + /// changing it after the channel is registered on device. + static const AndroidNotificationChannel routineBlockChannel = + AndroidNotificationChannel( + routineBlockId, + routineBlockName, + description: routineBlockDescription, + importance: Importance.high, + playSound: true, + sound: routineAndroidSound, + enableVibration: true, + ); + + /// Full platform-specific NotificationDetails for routine block notifications. + static NotificationDetails routineBlockDetails({ + String icon = 'ic_notification', + }) => + NotificationDetails( + android: AndroidNotificationDetails( + routineBlockId, + routineBlockName, + channelDescription: routineBlockDescription, + importance: Importance.high, + priority: Priority.high, + icon: icon, + enableVibration: true, + playSound: true, + sound: routineAndroidSound, + ), + iOS: DarwinNotificationDetails( + sound: routineIosSoundFile, + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ); +} diff --git a/lib/features/notifications/data/models/notification_nav.dart b/lib/features/notifications/data/models/notification_nav.dart new file mode 100644 index 00000000..3450ea89 --- /dev/null +++ b/lib/features/notifications/data/models/notification_nav.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class NotificationNav { + final String itemId; + final String itemType; + const NotificationNav({required this.itemId, required this.itemType}); +} + +/// Stores a pending deep-link from a notification tap. +/// Set by NotificationService; consumed and cleared by RoutineFilledState. +final pendingNotificationNavProvider = StateProvider((ref) => null); diff --git a/lib/features/notifications/data/services/notification_service.dart b/lib/features/notifications/data/services/notification_service.dart index 69cf0b6b..a744f07f 100644 --- a/lib/features/notifications/data/services/notification_service.dart +++ b/lib/features/notifications/data/services/notification_service.dart @@ -1,14 +1,28 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/features/notifications/data/channels/notification_channels.dart'; import 'package:flutter_pecha/features/home/presentation/screens/main_navigation_screen.dart'; +import 'package:flutter_pecha/features/notifications/data/models/notification_nav.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:go_router/go_router.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest.dart' as tz; +/// Top-level background notification tap handler. +/// Must be a top-level function annotated with @pragma so it survives AOT +/// tree-shaking and is callable from a separate background isolate (Android). +@pragma('vm:entry-point') +void onNotificationTapBackground(NotificationResponse notificationResponse) { + // Background isolate — cannot access Riverpod state or UI. + // The tap will be handled when the app comes to foreground. +} + final _logger = AppLogger('NotificationService'); class NotificationService { @@ -50,12 +64,14 @@ class NotificationService { /// Initialize without requesting permissions (for early app initialization) Future initializeWithoutPermissions() async { + _logger.info('[NOTIF-INIT] initializeWithoutPermissions called, already initialized=$_isInitialized'); if (_isInitialized) return; // prevent re-initialization // Initialize timezone: use device local time so scheduled notifications // (e.g. "7:10 AM") are in the user's local time, not UTC. tz.initializeTimeZones(); final currentTimezone = await FlutterTimezone.getLocalTimezone(); + _logger.info('[NOTIF-INIT] device timezone=$currentTimezone'); bool localSet = false; try { tz.setLocalLocation(tz.getLocation(currentTimezone)); @@ -113,6 +129,7 @@ class NotificationService { await notificationsPlugin.initialize( initSettings, onDidReceiveNotificationResponse: _onNotificationTapped, + onDidReceiveBackgroundNotificationResponse: onNotificationTapBackground, ); // Create notification channels for Android @@ -121,6 +138,128 @@ class NotificationService { } _isInitialized = true; + _logger.info('[NOTIF-INIT] initialization complete, isInitialized=$_isInitialized'); + + // Log diagnostics that affect terminated-state reliability. + if (Platform.isAndroid) { + await _logAndroidDiagnostics(); + } + + // Check if the app was launched by tapping a notification (terminated state). + // Store the details so they can be consumed after the router is ready. + final launchDetails = await notificationsPlugin.getNotificationAppLaunchDetails(); + if (launchDetails?.didNotificationLaunchApp == true) { + _launchNotificationResponse = launchDetails!.notificationResponse; + _logger.info('App launched from notification ID=${_launchNotificationResponse?.id}'); + } + } + + NotificationResponse? _launchNotificationResponse; + + /// Call this once the router is ready to consume any pending launch navigation. + void consumeLaunchNotification() { + final response = _launchNotificationResponse; + if (response == null) return; + _launchNotificationResponse = null; + _onNotificationTapped(response); + } + + /// Logs key Android diagnostics that affect whether notifications fire + /// when the app is in the background or terminated state. + Future _logAndroidDiagnostics() async { + try { + final androidImpl = notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + + final canExact = await androidImpl?.canScheduleExactNotifications() ?? false; + final batteryExempt = + await Permission.ignoreBatteryOptimizations.isGranted; + + _logger.info( + '[NOTIF-DIAG] canScheduleExactNotifications=$canExact ' + 'batteryOptimizationExempt=$batteryExempt', + ); + + if (!canExact) { + _logger.warning( + '[NOTIF-DIAG] ⚠️ Exact alarms NOT allowed — ' + 'notifications may fire late or not at all when app is terminated.', + ); + } + if (!batteryExempt) { + _logger.warning( + '[NOTIF-DIAG] ⚠️ Battery optimization is ACTIVE — ' + 'on some Android devices this kills alarms when the app is terminated. ' + 'User should go to Settings > Apps > WeBuddhist > Battery > Unrestricted.', + ); + } + } catch (e) { + _logger.warning('[NOTIF-DIAG] Could not read diagnostics: $e'); + } + } + + /// Returns true if this app is exempt from battery optimisation. + Future isBatteryOptimizationExempt() async { + if (!Platform.isAndroid) return true; + return Permission.ignoreBatteryOptimizations.isGranted; + } + + /// Opens the system dialog that lets the user exempt this app from + /// battery optimisation. Only needed on OEM devices (Samsung, Xiaomi, OnePlus). + Future requestBatteryOptimizationExemption() async { + if (!Platform.isAndroid) return true; + final status = await Permission.ignoreBatteryOptimizations.request(); + _logger.info('[NOTIF] Battery optimization exemption request result: $status'); + return status.isGranted; + } + + /// Returns true if exact alarms are permitted (Android 12+). + /// Always true on iOS/macOS and Android < 12. + Future canScheduleExactNotifications() async { + if (!Platform.isAndroid) return true; + final androidImpl = notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + return await androidImpl?.canScheduleExactNotifications() ?? false; + } + + /// Opens the Alarms & Reminders settings page for this app (Android 12+). + Future openExactAlarmSettings() async { + if (!Platform.isAndroid) return; + final androidImpl = notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + await androidImpl?.requestExactAlarmsPermission(); + } + + /// Reads the live system state of a notification channel. + /// Returns false if the user has muted the channel (importance = none) or + /// the channel doesn't exist. iOS/macOS have no channels — returns the + /// app-level permission instead. + Future isChannelEnabled(String channelId) async { + if (!Platform.isAndroid) return areNotificationsEnabled(); + final androidImpl = notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + final channels = await androidImpl?.getNotificationChannels() ?? []; + final channel = channels.where((c) => c.id == channelId).firstOrNull; + if (channel == null) return false; + return channel.importance != Importance.none; + } + + /// Opens the OS notification settings for a specific channel (Android 8+). + /// Falls back to opening the app-level notification settings on older devices. + Future openChannelSettings(String channelId) async { + if (!Platform.isAndroid) return; + const platform = MethodChannel('org.pecha.app/notifications'); + try { + await platform.invokeMethod('openChannelSettings', { + 'channelId': channelId, + }); + } catch (e) { + _logger.warning('openChannelSettings failed: $e'); + } } /// Create Android notification channels @@ -132,26 +271,16 @@ class NotificationService { >(); if (androidImplementation != null) { - // Routine block reminder channel (only channel needed now) - const AndroidNotificationChannel routineBlockChannel = - AndroidNotificationChannel( - routineBlockNotificationChannelId, - routineBlockNotificationChannelName, - description: routineBlockNotificationChannelDescription, - importance: Importance.high, - playSound: true, - enableVibration: true, - ); - await androidImplementation.createNotificationChannel( - routineBlockChannel, + NotificationChannels.routineBlockChannel, ); - _logger.info('Android notification channels created'); } } - /// Initialize with permission request (legacy method) + /// Initialize and immediately request notification permissions. + /// Use [initializeWithoutPermissions] + [requestPermission] separately + /// when you need finer control over the permission prompt timing. Future initialize() async { await initializeWithoutPermissions(); await requestPermission(); @@ -171,7 +300,7 @@ class NotificationService { await androidImplementation?.requestNotificationsPermission(); // For Android 12+, also request exact alarm permission - if (granted == true && Platform.isAndroid) { + if (granted == true) { await androidImplementation?.requestExactAlarmsPermission(); } @@ -218,40 +347,33 @@ class NotificationService { void _onNotificationTapped(NotificationResponse response) { _logger.info('Notification tapped - ID: ${response.id}, Payload: ${response.payload}'); - - // Navigate based on notification ID - if (_router == null) { - _logger.warning('Router not initialized, cannot navigate'); - return; - } - - if (_container == null) { - _logger.warning('Container not initialized, cannot navigate'); + + if (_router == null || _container == null) { + _logger.warning('Router/container not initialized, cannot navigate'); return; } - final currentUri = _router!.routerDelegate.currentConfiguration.uri; - _logger.debug('Current route: $currentUri'); - - // Routine block notifications have ID >= 1000 (range: 1000-999999) - // Legacy notifications use ID 100-999 - if (response.id != null && response.id! >= 100) { - _logger.info('Navigating to practice screen (routine notification)'); - // Routine block notification — navigate to practice screen (index 2) - _container!.read(mainNavigationIndexProvider.notifier).state = 3; - } else { - _logger.info('Navigating to home screen (default)'); - // Default fallback - go to home tab (index 0) - _container!.read(mainNavigationIndexProvider.notifier).state = 0; + // Parse payload and store as pending navigation — RoutineFilledState will + // consume it once it renders and plan data is available. + final payload = response.payload; + if (payload != null && payload.isNotEmpty) { + try { + final data = jsonDecode(payload) as Map; + final itemId = data['itemId'] as String?; + final itemTypeStr = data['itemType'] as String?; + if (itemId != null && itemTypeStr != null) { + _logger.info('Storing pending notification nav: $itemTypeStr $itemId'); + _container!.read(pendingNotificationNavProvider.notifier).state = + NotificationNav(itemId: itemId, itemType: itemTypeStr); + } + } catch (e) { + _logger.warning('Failed to parse notification payload: $e'); + } } - + + // Navigate to the practice tab — RoutineFilledState will push the detail screen. + _container!.read(mainNavigationIndexProvider.notifier).state = 2; _router!.go('/home'); - _logger.debug('Navigation completed'); } } -// Routine block notification constants -const routineBlockNotificationChannelId = 'routine_block_reminder'; -const routineBlockNotificationChannelName = 'Routine Block Reminder'; -const routineBlockNotificationChannelDescription = - 'Daily notifications for routine practice blocks'; diff --git a/lib/features/practice/data/services/routine_notification_service.dart b/lib/features/notifications/data/services/routine_notification_service.dart similarity index 66% rename from lib/features/practice/data/services/routine_notification_service.dart rename to lib/features/notifications/data/services/routine_notification_service.dart index 89471681..0fa19db8 100644 --- a/lib/features/practice/data/services/routine_notification_service.dart +++ b/lib/features/notifications/data/services/routine_notification_service.dart @@ -1,17 +1,15 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/features/notifications/data/channels/notification_channels.dart'; import 'package:flutter_pecha/features/notifications/data/services/notification_service.dart'; import 'package:flutter_pecha/features/practice/data/models/routine_model.dart'; import 'package:timezone/timezone.dart' as tz; final _logger = AppLogger('RoutineNotificationService'); -// Channel constants -const routineNotificationChannelId = 'routine_block_reminder'; -const routineNotificationChannelName = 'Routine Block Reminder'; -const routineNotificationChannelDescription = - 'Daily notifications for routine practice blocks'; - /// Result of a notification scheduling operation. class NotificationResult { final bool success; @@ -58,25 +56,46 @@ class RoutineNotificationService { factory RoutineNotificationService() => _instance; RoutineNotificationService._internal(); + // Allows injecting a mock plugin in tests without breaking the public API. + FlutterLocalNotificationsPlugin? _testPlugin; + + @visibleForTesting + factory RoutineNotificationService.withPlugin( + FlutterLocalNotificationsPlugin plugin, + ) { + final svc = RoutineNotificationService._internal(); + svc._testPlugin = plugin; + return svc; + } + FlutterLocalNotificationsPlugin get _plugin => - NotificationService().notificationsPlugin; + _testPlugin ?? NotificationService().notificationsPlugin; bool get _isReady => NotificationService().isInitialized; /// Schedule a daily repeating notification for a single block. - /// - /// Returns [NotificationResult] indicating success or failure with details. - Future scheduleBlockNotification(RoutineBlock block) async { + Future scheduleBlockNotification( + RoutineBlock block, + ) async { + _logger.info( + '[NOTIF-SCHEDULE] block=${block.id} time=${block.formattedTime} ' + 'notificationEnabled=${block.notificationEnabled} ' + 'items=${block.items.length} notificationId=${block.notificationId}', + ); + if (!block.notificationEnabled) { + _logger.info('[NOTIF-SCHEDULE] SKIPPED: notifications disabled for block'); return NotificationResult.skipped('Notifications disabled for block'); } if (block.items.isEmpty) { + _logger.info('[NOTIF-SCHEDULE] SKIPPED: block has no items'); return NotificationResult.skipped('Block has no items'); } + _logger.info('[NOTIF-SCHEDULE] _isReady=$_isReady'); if (!_isReady) { - _logger.warning('NotificationService not initialized, skipping schedule'); + _logger.warning('[NOTIF-SCHEDULE] FAILED: NotificationService not initialized'); return NotificationResult.failure('Notification service not initialized'); } @@ -95,43 +114,37 @@ class RoutineNotificationService { scheduledDate = scheduledDate.add(const Duration(days: 1)); } + _logger.info( + '[NOTIF-SCHEDULE] now=$now scheduledFor=$scheduledDate ' + 'tz=${tz.local.name}', + ); + final body = _getNotificationBody(block); + final firstItem = block.items.firstOrNull; + final payload = firstItem != null + ? jsonEncode({'itemId': firstItem.id, 'itemType': firstItem.type.name}) + : null; await _plugin.zonedSchedule( block.notificationId, 'Time for your practice', body, scheduledDate, - NotificationDetails( - android: AndroidNotificationDetails( - routineNotificationChannelId, - routineNotificationChannelName, - channelDescription: routineNotificationChannelDescription, - importance: Importance.high, - priority: Priority.high, - icon: 'ic_notification', - enableVibration: true, - playSound: true, - ), - iOS: const DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ), - ), + NotificationChannels.routineBlockDetails(), androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - matchDateTimeComponents: DateTimeComponents.time, // daily repeat + matchDateTimeComponents: DateTimeComponents.time, + payload: payload, ); _logger.info( - 'Scheduled routine notification ID=${block.notificationId} ' - 'at ${block.formattedTime}', + '[NOTIF-SCHEDULE] SUCCESS id=${block.notificationId} ' + 'fires=$scheduledDate body="$body"', ); return NotificationResult.success(block.notificationId); } catch (e, stackTrace) { _logger.error( - 'Failed to schedule notification for block ${block.id}', + '[NOTIF-SCHEDULE] ERROR scheduling block ${block.id}', e, stackTrace, ); @@ -140,31 +153,29 @@ class RoutineNotificationService { } /// Cancel notification for a single block. - /// - /// Safe to call even if the notification doesn't exist or service isn't ready. Future cancelBlockNotification(RoutineBlock block) async { if (!_isReady) return; try { await _plugin.cancel(block.notificationId); _logger.info('Cancelled routine notification ID=${block.notificationId}'); } catch (e) { - _logger.warning('Failed to cancel notification ${block.notificationId}: $e'); + _logger.warning( + 'Failed to cancel notification ${block.notificationId}: $e', + ); } } /// Synchronize notifications with the current block list. /// - /// This uses a safer approach: - /// 1. First schedules all new/updated notifications - /// 2. Then cancels notifications for removed blocks - /// - /// This ensures that if the app crashes mid-sync, notifications are more - /// likely to still be scheduled rather than lost. - /// - /// Returns [NotificationSyncResult] with details about the operation. - Future syncNotifications(List blocks) async { + /// Schedules active blocks first, then cancels inactive ones — so if the + /// app crashes mid-sync, notifications are more likely to remain scheduled. + Future syncNotifications( + List blocks, + ) async { + _logger.info('[NOTIF-SYNC] starting sync for ${blocks.length} blocks, _isReady=$_isReady'); + if (!_isReady) { - _logger.warning('NotificationService not initialized, skipping sync'); + _logger.warning('[NOTIF-SYNC] FAILED: NotificationService not initialized'); return const NotificationSyncResult( errors: ['Notification service not initialized'], ); @@ -176,11 +187,9 @@ class RoutineNotificationService { final errors = []; try { - // Step 1: Schedule notifications for active blocks first (safer - ensures - // notifications exist before we cancel old ones) - final activeBlocks = blocks.where( - (b) => b.notificationEnabled && b.items.isNotEmpty, - ).toList(); + final activeBlocks = blocks + .where((b) => b.notificationEnabled && b.items.isNotEmpty) + .toList(); final activeIds = {}; for (final block in activeBlocks) { @@ -196,11 +205,6 @@ class RoutineNotificationService { } } - // Step 2: Cancel notifications for inactive/removed blocks - // Cancel all blocks that are either: - // - Not in the active list (removed or disabled) - // - Have notifications disabled - // - Have no items for (final block in blocks) { if (!activeIds.contains(block.notificationId)) { await cancelBlockNotification(block); @@ -209,10 +213,11 @@ class RoutineNotificationService { } _logger.info( - 'Notification sync complete: $scheduled scheduled, $cancelled cancelled, $failed failed', + 'Notification sync complete: $scheduled scheduled, ' + '$cancelled cancelled, $failed failed', ); - } catch (e) { - _logger.error('Sync failed: $e'); + } catch (e, st) { + _logger.error('Sync failed', e, st); errors.add('Sync error: $e'); } @@ -234,8 +239,6 @@ class RoutineNotificationService { } /// Cancel a notification by ID directly. - /// - /// Useful when you need to cancel a notification but don't have the full block. Future cancelNotificationById(int notificationId) async { if (!_isReady) return; try { diff --git a/lib/features/notifications/domain/entities/notification.dart b/lib/features/notifications/domain/entities/notification.dart index 4b7e6eee..cad1ebae 100644 --- a/lib/features/notifications/domain/entities/notification.dart +++ b/lib/features/notifications/domain/entities/notification.dart @@ -24,4 +24,4 @@ class AppNotification extends BaseEntity { List get props => [id, title, body, scheduledTime, type, isRecurring, recurrencePattern]; } -enum NotificationType { practiceReminder, planReminder, newContent, announcement } +enum NotificationType { practiceReminder, planReminder, newContent, announcement, routine } diff --git a/lib/features/notifications/notifications.dart b/lib/features/notifications/notifications.dart index 21eab8cf..7b0c952a 100644 --- a/lib/features/notifications/notifications.dart +++ b/lib/features/notifications/notifications.dart @@ -17,7 +17,9 @@ export 'domain/repositories/notifications_repository.dart'; export 'domain/usecases/notifications_usecases.dart'; // Services +export 'data/channels/notification_channels.dart'; export 'data/services/notification_service.dart'; +export 'data/services/routine_notification_service.dart'; // Presentation - Providers export 'presentation/providers/notification_provider.dart'; diff --git a/lib/features/notifications/presentation/notification_settings_screen.dart b/lib/features/notifications/presentation/notification_settings_screen.dart index 2ecf57dc..a2cd0f0b 100644 --- a/lib/features/notifications/presentation/notification_settings_screen.dart +++ b/lib/features/notifications/presentation/notification_settings_screen.dart @@ -1,9 +1,15 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_pecha/core/config/locale/locale_notifier.dart'; +import 'package:flutter_pecha/features/notifications/data/channels/notification_channels.dart'; import 'package:flutter_pecha/features/notifications/presentation/providers/notification_provider.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/routine_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_pecha/core/l10n/generated/app_localizations.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:timezone/timezone.dart' as tz; class NotificationSettingsScreen extends ConsumerStatefulWidget { const NotificationSettingsScreen({super.key}); @@ -15,100 +21,311 @@ class NotificationSettingsScreen extends ConsumerStatefulWidget { } class _NotificationSettingsScreenState - extends ConsumerState { + extends ConsumerState + with WidgetsBindingObserver { + bool _isSchedulingTest = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // Re-read every OS-level setting the moment the user returns. + ref.read(notificationProvider.notifier).refreshStatus().then((_) { + // If the routine channel is on, make sure every block is scheduled. + // If it's off, Android simply won't show fires — no cleanup needed. + final s = ref.read(notificationProvider); + if (s.hasSystemPermission && s.routineChannelEnabled) { + ref + .read(notificationProvider.notifier) + .resyncRoutineNotifications(ref.read(routineProvider).blocks); + } + }); + } + } + + // ── Toggle handlers ──────────────────────────────────────────────────────── + + Future _toggleMaster(bool enable) async { + if (enable) { + final granted = await ref + .read(notificationProvider.notifier) + .requestEnableNotifications(); + if (!granted) { + _snack('Permission denied — opening App Settings.'); + await openAppSettings(); + } + } else { + _snack('Opening App Settings — turn off notifications there.'); + await openAppSettings(); + } + } + + Future _toggleRoutineChannel(bool _) async { + if (Platform.isAndroid) { + // Android: open the exact notification channel page. + await ref + .read(notificationServiceProvider) + .openChannelSettings(NotificationChannels.routineBlockId); + } else { + // iOS: no per-channel control — open the app notification settings page. + _snack('Opening Settings — manage notifications there.'); + await openAppSettings(); + } + } + + Future _toggleExactAlarms(bool enable) async { + if (enable) { + await ref.read(notificationServiceProvider).openExactAlarmSettings(); + } else { + _snack('Opening App Settings — disable Alarms & Reminders there.'); + await openAppSettings(); + } + } + + Future _toggleBattery(bool exempt) async { + if (exempt) { + // REQUEST_IGNORE_BATTERY_OPTIMIZATIONS shows a system dialog directly. + await ref + .read(notificationServiceProvider) + .requestBatteryOptimizationExemption(); + ref.read(notificationProvider.notifier).refreshStatus(); + } else { + _snack('Opening App Settings — re-enable optimization under Battery.'); + await openAppSettings(); + } + } + + Future _scheduleTestNotification() async { + final service = ref.read(notificationServiceProvider); + if (!service.isInitialized) { + _snack('Notification service not ready.'); + return; + } + setState(() => _isSchedulingTest = true); + try { + const testId = 9999; + await service.notificationsPlugin.cancel(testId); + final at = tz.TZDateTime.now(tz.local).add(const Duration(minutes: 4)); + await service.notificationsPlugin.zonedSchedule( + testId, + 'Routine Notification Test', + 'Fires at ${_hhmm(at)} — custom sound test', + at, + NotificationChannels.routineBlockDetails(), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); + _snack('Test scheduled for ${_hhmm(at)} — close the app to verify.'); + } catch (e) { + _snack('Failed: $e'); + } finally { + if (mounted) setState(() => _isSchedulingTest = false); + } + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + + String _hhmm(tz.TZDateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + // ── Build ────────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final state = ref.watch(notificationProvider); - final hasPermission = state.hasPermission; - final localizations = AppLocalizations.of(context)!; final locale = ref.watch(localeProvider); - final languageCode = locale.languageCode; - final titleFontSize = languageCode == 'bo' ? 20.0 : 16.0; - final subtitleFontSize = languageCode == 'bo' ? 18.0 : 14.0; + final isbo = locale.languageCode == 'bo'; + final ts = isbo ? 20.0 : 16.0; + final ss = isbo ? 17.0 : 13.5; return Scaffold( appBar: AppBar(title: Text(localizations.notification_settings)), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Permission Status Card - if (!hasPermission) ...[ - Card( - color: Theme.of(context).colorScheme.surface, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Icon( - Icons.warning, - color: Theme.of(context).colorScheme.error, - size: 48, - ), - const SizedBox(height: 8), - Text( - localizations.notification_enable_message, - textAlign: TextAlign.center, - style: TextStyle(fontSize: subtitleFontSize), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () async { - final granted = - await ref - .read(notificationServiceProvider) - .requestPermission(); - if (granted) { - ref - .read(notificationProvider.notifier) - .checkPermissionStatus(); - } else { - await openAppSettings(); - } - }, - child: Text( - localizations.enable_notification, - style: TextStyle(fontSize: titleFontSize), - ), - ), - ], - ), + body: state.isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + children: [ + // ── 1. Master (app-level notification permission) ───── + _label('Notifications', ts, context), + _SwitchTile( + icon: state.hasSystemPermission + ? Icons.notifications_active + : Icons.notifications_off, + title: 'Allow Notifications', + subtitle: state.hasSystemPermission + ? 'Notifications are enabled for this app' + : 'Tap to enable — shows system prompt or opens Settings', + value: state.hasSystemPermission, + onChanged: _toggleMaster, + titleSize: ts, + subtitleSize: ss, ), - ), - const SizedBox(height: 16), - ], - if (hasPermission) ...[ - Card( - color: Theme.of(context).cardColor, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - const Icon( - Icons.check_circle, - color: Colors.green, - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Notifications are enabled', - style: TextStyle(fontSize: subtitleFontSize), - ), - ), - ], + + if (state.hasSystemPermission) ...[ + const SizedBox(height: 24), + + // ── 2. Per-channel categories ────────────────────── + _label('Categories', ts, context), + _SwitchTile( + icon: Icons.self_improvement, + title: 'Routine Reminders', + subtitle: state.routineChannelEnabled + ? 'Daily reminders for your practice blocks' + : 'Muted — tap to re-enable in system settings', + value: state.routineChannelEnabled, + onChanged: _toggleRoutineChannel, + titleSize: ts, + subtitleSize: ss, ), - ), - ), - ], - if (state.isLoading) ...[ - const SizedBox(height: 16), - const Center(child: CircularProgressIndicator()), - ], - ], + + // ── 3. Alarms & Reminders (Android only) ─────────── + if (Platform.isAndroid) ...[ + const SizedBox(height: 24), + _label('Alarms & Reminders', ts, context), + _SwitchTile( + icon: Icons.alarm, + title: 'Exact Alarms', + subtitle: state.canScheduleExactAlarms + ? 'Notifications fire at the exact scheduled time' + : 'Required on Android 12+ — tap to grant', + value: state.canScheduleExactAlarms, + onChanged: _toggleExactAlarms, + titleSize: ts, + subtitleSize: ss, + ), + ], + + // ── 4. Battery optimization (Android only) ───────── + if (Platform.isAndroid) ...[ + const SizedBox(height: 24), + _label('Battery · Optional', ts, context), + _SwitchTile( + icon: Icons.battery_charging_full, + title: 'Unrestricted Battery', + subtitle: state.isBatteryOptimizationExempt + ? 'App is exempt from battery optimization — reminders will fire on time even when the app is closed or the phone is idle' + : 'Devices such as OnePlus, Xiaomi, Redmi etc may kill background apps. Enable this to keep notifications reliable.', + value: state.isBatteryOptimizationExempt, + onChanged: _toggleBattery, + titleSize: ts, + subtitleSize: ss, + ), + ], + + const SizedBox(height: 24), + + // ── 5. Test ──────────────────────────────────────── + // _label('Diagnostics', ts, context), + // Card( + // margin: EdgeInsets.zero, + // child: ListTile( + // leading: _isSchedulingTest + // ? const SizedBox( + // width: 24, + // height: 24, + // child: + // CircularProgressIndicator(strokeWidth: 2), + // ) + // : Icon( + // Icons.notifications_active, + // color: Theme.of(context).colorScheme.primary, + // ), + // title: Text( + // 'Send Test Notification', + // style: TextStyle( + // fontSize: ts, fontWeight: FontWeight.w500), + // ), + // subtitle: Text( + // 'Fires in 4 minutes — close the app to verify', + // style: TextStyle(fontSize: ss), + // ), + // trailing: _isSchedulingTest + // ? null + // : const Icon(Icons.chevron_right), + // onTap: + // _isSchedulingTest ? null : _scheduleTestNotification, + // contentPadding: const EdgeInsets.symmetric( + // horizontal: 16, vertical: 4), + // ), + // ), + ], + + const SizedBox(height: 32), + ], + ), + ); + } + + Widget _label(String text, double fontSize, BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + text.toUpperCase(), + style: TextStyle( + fontSize: fontSize - 3, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + color: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ); +} + +class _SwitchTile extends StatelessWidget { + const _SwitchTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + required this.titleSize, + required this.subtitleSize, + }); + + final IconData icon; + final String title; + final String subtitle; + final bool value; + final ValueChanged onChanged; + final double titleSize; + final double subtitleSize; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Card( + margin: EdgeInsets.zero, + child: SwitchListTile( + secondary: Icon( + icon, + color: + value ? cs.primary : cs.onSurface.withValues(alpha: 0.4), + ), + title: Text( + title, + style: TextStyle(fontSize: titleSize, fontWeight: FontWeight.w500), ), + subtitle: Text(subtitle, style: TextStyle(fontSize: subtitleSize)), + value: value, + onChanged: onChanged, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ), ); } diff --git a/lib/features/notifications/presentation/providers/notification_provider.dart b/lib/features/notifications/presentation/providers/notification_provider.dart index 0342dc0b..1f6bed72 100644 --- a/lib/features/notifications/presentation/providers/notification_provider.dart +++ b/lib/features/notifications/presentation/providers/notification_provider.dart @@ -1,62 +1,103 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_pecha/features/notifications/data/channels/notification_channels.dart'; import 'package:flutter_pecha/features/notifications/data/services/notification_service.dart'; +import 'package:flutter_pecha/features/notifications/data/services/routine_notification_service.dart'; +import 'package:flutter_pecha/features/practice/data/models/routine_model.dart'; -/// Simplified notification state - only tracks permission status +/// Everything in this state is a live read of an OS-level setting. +/// The app never stores its own copy — toggling a switch just redirects the +/// user to the matching system settings page, and [refreshStatus] reloads +/// the truth when they return. class NotificationState { final bool isLoading; - final bool hasPermission; + final bool hasSystemPermission; + final bool routineChannelEnabled; + final bool canScheduleExactAlarms; + final bool isBatteryOptimizationExempt; const NotificationState({ this.isLoading = false, - this.hasPermission = false, + this.hasSystemPermission = false, + this.routineChannelEnabled = false, + this.canScheduleExactAlarms = true, + this.isBatteryOptimizationExempt = true, }); NotificationState copyWith({ bool? isLoading, - bool? hasPermission, - }) { - return NotificationState( - isLoading: isLoading ?? this.isLoading, - hasPermission: hasPermission ?? this.hasPermission, - ); - } + bool? hasSystemPermission, + bool? routineChannelEnabled, + bool? canScheduleExactAlarms, + bool? isBatteryOptimizationExempt, + }) => + NotificationState( + isLoading: isLoading ?? this.isLoading, + hasSystemPermission: hasSystemPermission ?? this.hasSystemPermission, + routineChannelEnabled: + routineChannelEnabled ?? this.routineChannelEnabled, + canScheduleExactAlarms: + canScheduleExactAlarms ?? this.canScheduleExactAlarms, + isBatteryOptimizationExempt: + isBatteryOptimizationExempt ?? this.isBatteryOptimizationExempt, + ); } -/// Simplified notifier - only manages permission status class NotificationNotifier extends StateNotifier { - final NotificationService _notificationService; + final NotificationService _service; - NotificationNotifier(this._notificationService) - : super(const NotificationState()) { - _loadPermissionStatus(); + NotificationNotifier(this._service) : super(const NotificationState()) { + refreshStatus(initial: true); } - Future _loadPermissionStatus() async { - state = state.copyWith(isLoading: true); - + /// Re-reads every OS-level permission state. + /// Called on init, and whenever the app resumes from a system Settings page. + Future refreshStatus({bool initial = false}) async { + if (initial) state = state.copyWith(isLoading: true); try { - final hasPermission = - await _notificationService.areNotificationsEnabled(); - + final results = await Future.wait([ + _service.areNotificationsEnabled(), + _service.isChannelEnabled(NotificationChannels.routineBlockId), + _service.canScheduleExactNotifications(), + _service.isBatteryOptimizationExempt(), + ]); state = state.copyWith( - hasPermission: hasPermission, + hasSystemPermission: results[0], + routineChannelEnabled: results[1], + canScheduleExactAlarms: results[2], + isBatteryOptimizationExempt: results[3], isLoading: false, ); - } catch (e) { - state = state.copyWith(isLoading: false); + } catch (_) { + if (initial) state = state.copyWith(isLoading: false); } } - Future checkPermissionStatus() async { - final hasPermission = await _notificationService.areNotificationsEnabled(); - state = state.copyWith(hasPermission: hasPermission); + /// Shows the Android/iOS system permission dialog. Returns false if the + /// user denied (or the OS silently declined because the dialog was already + /// dismissed before). + Future requestEnableNotifications() async { + final granted = await _service.requestPermission(); + state = state.copyWith(hasSystemPermission: granted); + // Also re-read the channel state — channels default to enabled on grant. + if (granted) { + final channelEnabled = await _service + .isChannelEnabled(NotificationChannels.routineBlockId); + state = state.copyWith(routineChannelEnabled: channelEnabled); + } + return granted; + } + + /// Re-syncs scheduled routine notifications. Use after returning from + /// system settings in case the user re-enabled the channel. + Future resyncRoutineNotifications(List blocks) async { + await RoutineNotificationService().syncNotifications(blocks); } } final notificationProvider = StateNotifierProvider((ref) { - return NotificationNotifier(NotificationService()); - }); + return NotificationNotifier(NotificationService()); +}); final notificationServiceProvider = Provider((ref) { return NotificationService(); diff --git a/lib/features/practice/data/repositories/practice_repository_impl.dart b/lib/features/practice/data/repositories/practice_repository_impl.dart index 32b6139c..4d765d38 100644 --- a/lib/features/practice/data/repositories/practice_repository_impl.dart +++ b/lib/features/practice/data/repositories/practice_repository_impl.dart @@ -4,7 +4,7 @@ import 'package:uuid/uuid.dart'; import 'package:flutter_pecha/core/error/failures.dart'; import 'package:flutter_pecha/features/practice/data/datasource/routine_local_storage.dart'; import 'package:flutter_pecha/features/practice/data/models/routine_model.dart'; -import 'package:flutter_pecha/features/practice/data/services/routine_notification_service.dart'; +import 'package:flutter_pecha/features/notifications/data/services/routine_notification_service.dart'; import 'package:flutter_pecha/features/practice/domain/entities/practice_progress.dart'; import 'package:flutter_pecha/features/practice/domain/entities/practice_session.dart'; import 'package:flutter_pecha/features/practice/domain/entities/routine.dart'; diff --git a/lib/features/practice/practice.dart b/lib/features/practice/practice.dart index b8d8c1aa..f55af099 100644 --- a/lib/features/practice/practice.dart +++ b/lib/features/practice/practice.dart @@ -29,7 +29,7 @@ export 'data/models/routine_api_models.dart'; export 'data/models/session_selection.dart'; // Data - Services -export 'data/services/routine_notification_service.dart'; +export 'package:flutter_pecha/features/notifications/data/services/routine_notification_service.dart'; // Data - Utils export 'data/utils/routine_time_utils.dart'; diff --git a/lib/features/practice/presentation/providers/practice_providers.dart b/lib/features/practice/presentation/providers/practice_providers.dart index 26dceafe..7f4d9dd8 100644 --- a/lib/features/practice/presentation/providers/practice_providers.dart +++ b/lib/features/practice/presentation/providers/practice_providers.dart @@ -1,6 +1,6 @@ import 'package:flutter_pecha/features/practice/data/datasource/routine_local_storage.dart'; import 'package:flutter_pecha/features/practice/data/repositories/practice_repository_impl.dart'; -import 'package:flutter_pecha/features/practice/data/services/routine_notification_service.dart'; +import 'package:flutter_pecha/features/notifications/data/services/routine_notification_service.dart'; import 'package:flutter_pecha/features/practice/domain/repositories/practice_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/lib/features/practice/presentation/providers/routine_provider.dart b/lib/features/practice/presentation/providers/routine_provider.dart index 8cc20c78..756008c9 100644 --- a/lib/features/practice/presentation/providers/routine_provider.dart +++ b/lib/features/practice/presentation/providers/routine_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter_pecha/core/utils/app_logger.dart'; import 'package:flutter_pecha/features/practice/data/datasource/routine_local_storage.dart'; import 'package:flutter_pecha/features/practice/data/models/routine_model.dart'; -import 'package:flutter_pecha/features/practice/data/services/routine_notification_service.dart'; +import 'package:flutter_pecha/features/notifications/data/services/routine_notification_service.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -33,16 +33,26 @@ class RoutineNotifier extends StateNotifier { _loadRoutines(); } - /// Load routines from local storage (Hive). + /// Load routines from local storage (Hive) and re-sync notifications. + /// Re-syncing on startup ensures alarms are registered even after app + /// updates or edge cases where AlarmManager entries were cleared. Future _loadRoutines() async { try { final data = await _localStorage.loadRoutine(); if (mounted) { state = data; - _logger.info('Loaded ${data.blocks.length} routine blocks from storage'); + _logger.info('[ROUTINE-LOAD] Loaded ${data.blocks.length} blocks from storage'); + if (data.blocks.isNotEmpty) { + _logger.info('[ROUTINE-LOAD] Re-syncing ${data.blocks.length} notifications on startup...'); + final result = await _notificationService.syncNotifications(data.blocks); + _logger.info( + '[ROUTINE-LOAD] Startup sync done: scheduled=${result.scheduled} ' + 'cancelled=${result.cancelled} failed=${result.failed}', + ); + } } } catch (e) { - _logger.error('Failed to load routines', e); + _logger.error('[ROUTINE-LOAD] Failed to load routines', e); if (mounted) { state = const RoutineData(); } @@ -52,21 +62,28 @@ class RoutineNotifier extends StateNotifier { /// Save routine blocks to persistent storage and sync notifications. Future saveRoutine(List blocks) async { final data = RoutineData(blocks: blocks).sortedByTime; + _logger.info('[ROUTINE-SAVE] saving ${data.blocks.length} blocks'); try { // 1. Persist to Hive storage await _localStorage.saveRoutine(data); - _logger.info('Saved ${data.blocks.length} routine blocks to storage'); + _logger.info('[ROUTINE-SAVE] persisted to storage'); // 2. Sync notifications - await _notificationService.syncNotifications(data.blocks); + _logger.info('[ROUTINE-SAVE] calling syncNotifications...'); + final syncResult = await _notificationService.syncNotifications(data.blocks); + _logger.info( + '[ROUTINE-SAVE] sync done: scheduled=${syncResult.scheduled} ' + 'cancelled=${syncResult.cancelled} failed=${syncResult.failed} ' + 'errors=${syncResult.errors}', + ); // 3. Update in-memory state if (mounted) { state = data; } } catch (e) { - _logger.error('Failed to save routine', e); + _logger.error('[ROUTINE-SAVE] failed', e); rethrow; } } diff --git a/lib/features/practice/presentation/screens/edit_routine_screen.dart b/lib/features/practice/presentation/screens/edit_routine_screen.dart index 479a5504..d1d3d0de 100644 --- a/lib/features/practice/presentation/screens/edit_routine_screen.dart +++ b/lib/features/practice/presentation/screens/edit_routine_screen.dart @@ -14,6 +14,7 @@ import 'package:flutter_pecha/features/practice/data/utils/routine_api_mapper.da import 'package:flutter_pecha/features/practice/data/utils/routine_time_utils.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_providers.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/routine_api_providers.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/routine_provider.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/select_session_screen.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/routine_time_block.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -170,11 +171,14 @@ class _EditRoutineScreenState extends ConsumerState { ); } - Future _syncNotifications() async { + /// Saves blocks to local Hive storage AND syncs notifications. + /// Routing through RoutineNotifier keeps Hive in sync so startup + /// reschedule works, and ensures [ROUTINE-SAVE] logs are visible. + Future _saveLocalAndSyncNotifications() async { final blocks = _blocks.map(_toRoutineBlock).toList(); - await ref - .read(routineNotificationServiceProvider) - .syncNotifications(blocks); + _logger.info('[EDIT-SAVE] persisting ${blocks.length} blocks to Hive + scheduling notifications'); + await ref.read(routineProvider.notifier).saveRoutine(blocks); + _logger.info('[EDIT-SAVE] done'); } // ─── Operation queue ─── @@ -305,9 +309,9 @@ class _EditRoutineScreenState extends ConsumerState { } try { - await _syncNotifications(); + await _saveLocalAndSyncNotifications(); } catch (e, st) { - _logger.error('Failed to sync notifications on save', e, st); + _logger.error('[EDIT-SAVE] Failed to save/sync notifications', e, st); if (mounted) _showErrorSnackBar(_mapError(e)); } diff --git a/lib/features/practice/presentation/widgets/routine_filled_state.dart b/lib/features/practice/presentation/widgets/routine_filled_state.dart index 7ed72320..fe7d98f4 100644 --- a/lib/features/practice/presentation/widgets/routine_filled_state.dart +++ b/lib/features/practice/presentation/widgets/routine_filled_state.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; +import 'package:flutter_pecha/features/notifications/data/models/notification_nav.dart'; import 'package:flutter_pecha/features/plans/presentation/providers/user_plans_provider.dart'; import 'package:flutter_pecha/features/practice/data/models/routine_model.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/routine_item_card.dart'; @@ -25,6 +26,42 @@ class RoutineFilledState extends ConsumerWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final dateStr = DateFormat('EEE, MMM d').format(DateTime.now()); + // Handle deep-link from notification tap. + final pendingNav = ref.watch(pendingNotificationNavProvider); + final myPlansState = ref.watch(myPlansPaginatedProvider); + if (pendingNav != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + final itemType = RoutineItemType.values.firstWhere( + (e) => e.name == pendingNav.itemType, + orElse: () => RoutineItemType.plan, + ); + if (itemType == RoutineItemType.recitation) { + ref.read(pendingNotificationNavProvider.notifier).state = null; + context.push( + '/reader/${pendingNav.itemId}', + extra: NavigationContext(source: NavigationSource.normal), + ); + } else { + final userPlan = myPlansState.plans + .where((p) => p.id == pendingNav.itemId) + .firstOrNull; + if (userPlan == null) return; // plans not loaded yet — wait for next build + ref.read(pendingNotificationNavProvider.notifier).state = null; + final startDate = userPlan.startedAt; + final daysSince = DateTime.now() + .difference(DateUtils.dateOnly(startDate)) + .inDays; + final selectedDay = (daysSince + 1).clamp(1, userPlan.totalDays); + context.push('/practice/details', extra: { + 'plan': userPlan, + 'selectedDay': selectedDay, + 'startDate': startDate, + }); + } + }); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/main.dart b/lib/main.dart index 3f697149..a713ed3d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -71,6 +71,15 @@ void main() async { } } + // Initialize notification service early so scheduled notifications can fire + // even when the app is in the background or was just launched from a tap. + try { + await NotificationService().initializeWithoutPermissions(); + _logger.info('Notification service initialized'); + } catch (e) { + _logger.warning('Error initializing notification service: $e'); + } + // Initialize routine local storage (persistent user data, not cache) final routineStorage = RoutineLocalStorage(); try { @@ -107,6 +116,7 @@ class MyApp extends ConsumerWidget { ref.watch(audioHandlerProvider); ref.watch(notificationServiceProvider); NotificationService.setRouter(router); + NotificationService().consumeLaunchNotification(); // Add QueryClient provider wrapper return QueryClientProvider( diff --git a/pubspec.yaml b/pubspec.yaml index 8f98ad4f..4481833a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -174,6 +174,8 @@ flutter: - assets/images/recitation_cover/ - assets/fonts/ - assets/audios/ + - assets/audios/routine.caf + - assets/audios/routine.ogg - .env.dev - .env.staging - .env.prod diff --git a/test/features/notifications/notification_channels_test.dart b/test/features/notifications/notification_channels_test.dart new file mode 100644 index 00000000..8677e7bb --- /dev/null +++ b/test/features/notifications/notification_channels_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_pecha/features/notifications/data/channels/notification_channels.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NotificationChannels', () { + group('routineBlock constants', () { + test('channel ID is stable — changing it silently breaks existing scheduled notifications on devices', () { + expect(NotificationChannels.routineBlockId, 'routine_block_reminder'); + }); + + test('channel name is correct', () { + expect(NotificationChannels.routineBlockName, 'Routine Block Reminder'); + }); + + test('iOS sound file is routine.caf', () { + expect(NotificationChannels.routineIosSoundFile, 'routine.caf'); + }); + + test('Android sound references res/raw/routine without extension', () { + expect( + NotificationChannels.routineAndroidSound, + isA(), + ); + expect(NotificationChannels.routineAndroidSound.sound, 'routine'); + }); + }); + + group('routineBlockChannel', () { + test('importance is high', () { + expect( + NotificationChannels.routineBlockChannel.importance, + Importance.high, + ); + }); + + test('playSound is true', () { + expect(NotificationChannels.routineBlockChannel.playSound, isTrue); + }); + + test('vibration is enabled', () { + expect( + NotificationChannels.routineBlockChannel.enableVibration, + isTrue, + ); + }); + + test('sound is set on channel (Android 8+ requires this for custom sound)', () { + expect( + NotificationChannels.routineBlockChannel.sound, + isA(), + ); + final sound = NotificationChannels.routineBlockChannel.sound + as RawResourceAndroidNotificationSound; + expect(sound.sound, 'routine'); + }); + + test('channel ID matches routineBlockId constant', () { + expect( + NotificationChannels.routineBlockChannel.id, + NotificationChannels.routineBlockId, + ); + }); + }); + + group('routineBlockDetails', () { + test('Android details use correct channel ID', () { + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.channelId, NotificationChannels.routineBlockId); + }); + + test('Android details have custom sound', () { + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.sound, isA()); + final sound = android.sound as RawResourceAndroidNotificationSound; + expect(sound.sound, 'routine'); + }); + + test('Android details have playSound true', () { + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.playSound, isTrue); + }); + + test('Android details have importance high', () { + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.importance, Importance.high); + }); + + test('Android details have priority high', () { + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.priority, Priority.high); + }); + + test('iOS details have sound routine.caf', () { + final details = NotificationChannels.routineBlockDetails(); + final ios = details.iOS! as DarwinNotificationDetails; + expect(ios.sound, 'routine.caf'); + }); + + test('iOS details have presentAlert true', () { + final details = NotificationChannels.routineBlockDetails(); + final ios = details.iOS! as DarwinNotificationDetails; + expect(ios.presentAlert, isTrue); + }); + + test('iOS details have presentSound true', () { + final details = NotificationChannels.routineBlockDetails(); + final ios = details.iOS! as DarwinNotificationDetails; + expect(ios.presentSound, isTrue); + }); + + test('custom icon is passed through to Android details', () { + final details = NotificationChannels.routineBlockDetails( + icon: 'custom_icon', + ); + final android = details.android! as AndroidNotificationDetails; + expect(android.icon, 'custom_icon'); + }); + }); + }); +} diff --git a/test/features/notifications/routine_notification_service_test.dart b/test/features/notifications/routine_notification_service_test.dart new file mode 100644 index 00000000..6e3615f3 --- /dev/null +++ b/test/features/notifications/routine_notification_service_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_pecha/features/notifications/data/channels/notification_channels.dart'; +import 'package:flutter_pecha/features/notifications/data/services/routine_notification_service.dart'; +import 'package:flutter_pecha/features/practice/data/models/routine_model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'routine_notification_service_test.mocks.dart'; + +// Run after creating this file: +// flutter pub run build_runner build --delete-conflicting-outputs +@GenerateMocks([FlutterLocalNotificationsPlugin]) +void main() { + late MockFlutterLocalNotificationsPlugin mockPlugin; + late RoutineNotificationService service; + + // Helper to build a RoutineBlock for tests. + RoutineBlock _block({ + String id = 'block-1', + int hour = 8, + int minute = 0, + bool notificationEnabled = true, + List items = const [], + int? notificationId, + }) { + return RoutineBlock( + id: id, + time: TimeOfDay(hour: hour, minute: minute), + notificationEnabled: notificationEnabled, + items: items, + notificationId: notificationId ?? 1001, + ); + } + + RoutineItem _item({String id = 'item-1', String title = 'Morning Prayer'}) { + return RoutineItem( + id: id, + title: title, + type: RoutineItemType.plan, + ); + } + + setUp(() { + mockPlugin = MockFlutterLocalNotificationsPlugin(); + service = RoutineNotificationService.withPlugin(mockPlugin); + + // Default stubs — can be overridden per test. + when( + mockPlugin.zonedSchedule( + any, + any, + any, + any, + any, + androidScheduleMode: anyNamed('androidScheduleMode'), + matchDateTimeComponents: anyNamed('matchDateTimeComponents'), + ), + ).thenAnswer((_) async {}); + + when(mockPlugin.cancel(any)).thenAnswer((_) async {}); + when(mockPlugin.cancelAll()).thenAnswer((_) async {}); + }); + + // ── scheduleBlockNotification ───────────────────────────────────────────── + + group('scheduleBlockNotification', () { + test('returns skipped when notificationEnabled is false', () async { + final block = _block(notificationEnabled: false, items: [_item()]); + final result = await service.scheduleBlockNotification(block); + + expect(result.success, isTrue); + expect(result.notificationId, isNull); + verifyNever(mockPlugin.zonedSchedule( + any, any, any, any, any, + androidScheduleMode: anyNamed('androidScheduleMode'), + matchDateTimeComponents: anyNamed('matchDateTimeComponents'), + )); + }); + + test('returns skipped when block has no items', () async { + final block = _block(notificationEnabled: true, items: []); + final result = await service.scheduleBlockNotification(block); + + expect(result.success, isTrue); + expect(result.notificationId, isNull); + verifyNever(mockPlugin.zonedSchedule( + any, any, any, any, any, + androidScheduleMode: anyNamed('androidScheduleMode'), + matchDateTimeComponents: anyNamed('matchDateTimeComponents'), + )); + }); + + test('returns failure when notification service is not initialized', () async { + // Service created without a NotificationService initialized — _isReady is false. + // Use a fresh service that has no initialized NotificationService. + final uninitializedService = RoutineNotificationService.withPlugin(mockPlugin); + // We cannot easily override _isReady, but the withPlugin factory creates + // a new instance that delegates isReady to NotificationService singleton. + // Since NotificationService is not initialized in tests, _isReady = false. + final block = _block(items: [_item()]); + final result = await uninitializedService.scheduleBlockNotification(block); + + // _isReady is false so we expect failure + expect(result.success, isFalse); + expect(result.errorMessage, contains('not initialized')); + }); + + test('calls zonedSchedule with routineBlockId channel', () async { + // We need _isReady = true — test this through the channel ID captured + // via the ArgumentCaptor approach. + // Since we cannot trivially set isReady=true in unit tests without + // a full NotificationService, we verify the channel constants directly + // on the NotificationDetails that would be passed. + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.channelId, NotificationChannels.routineBlockId); + }); + + test('NotificationDetails Android sound is RawResourceAndroidNotificationSound("routine")', () { + final details = NotificationChannels.routineBlockDetails(); + final android = details.android! as AndroidNotificationDetails; + expect(android.sound, isA()); + final sound = android.sound as RawResourceAndroidNotificationSound; + expect(sound.sound, 'routine'); + }); + + test('NotificationDetails iOS sound is routine.caf', () { + final details = NotificationChannels.routineBlockDetails(); + final ios = details.iOS! as DarwinNotificationDetails; + expect(ios.sound, 'routine.caf'); + }); + }); + + // ── cancelBlockNotification ─────────────────────────────────────────────── + + group('cancelBlockNotification', () { + test('does not throw when service is not ready', () async { + final block = _block(); + // Service's _isReady is false in test environment — should be a no-op. + await expectLater( + service.cancelBlockNotification(block), + completes, + ); + }); + }); + + // ── syncNotifications ───────────────────────────────────────────────────── + + group('syncNotifications', () { + test('returns error result when service is not ready', () async { + final result = await service.syncNotifications([_block(items: [_item()])]); + expect(result.hasErrors, isTrue); + expect(result.errors.first, contains('not initialized')); + }); + + test('returns zero counts when blocks list is empty and not ready', () async { + final result = await service.syncNotifications([]); + expect(result.scheduled, 0); + expect(result.failed, 0); + }); + }); + + // ── cancelAllBlockNotifications ─────────────────────────────────────────── + + group('cancelAllBlockNotifications', () { + test('does not throw when called with empty list', () async { + await expectLater( + service.cancelAllBlockNotifications([]), + completes, + ); + }); + + test('does not throw when called with blocks when not ready', () async { + final blocks = [_block(items: [_item()])]; + await expectLater( + service.cancelAllBlockNotifications(blocks), + completes, + ); + }); + }); + + // ── NotificationResult factory constructors ─────────────────────────────── + + group('NotificationResult', () { + test('success sets success=true and notificationId', () { + final result = NotificationResult.success(42); + expect(result.success, isTrue); + expect(result.notificationId, 42); + expect(result.errorMessage, isNull); + }); + + test('failure sets success=false and errorMessage', () { + final result = NotificationResult.failure('oops'); + expect(result.success, isFalse); + expect(result.errorMessage, 'oops'); + expect(result.notificationId, isNull); + }); + + test('skipped sets success=true with a reason message', () { + final result = NotificationResult.skipped('disabled'); + expect(result.success, isTrue); + expect(result.errorMessage, 'disabled'); + expect(result.notificationId, isNull); + }); + }); + + // ── NotificationSyncResult ──────────────────────────────────────────────── + + group('NotificationSyncResult', () { + test('default values are all zero with no errors', () { + const result = NotificationSyncResult(); + expect(result.scheduled, 0); + expect(result.failed, 0); + expect(result.cancelled, 0); + expect(result.errors, isEmpty); + expect(result.hasErrors, isFalse); + expect(result.isFullySuccessful, isTrue); + }); + + test('hasErrors is true when errors list is non-empty', () { + const result = NotificationSyncResult(errors: ['something failed']); + expect(result.hasErrors, isTrue); + expect(result.isFullySuccessful, isFalse); + }); + + test('isFullySuccessful is false when failed > 0', () { + const result = NotificationSyncResult(failed: 1); + expect(result.isFullySuccessful, isFalse); + }); + }); +} diff --git a/test/features/notifications/routine_notification_service_test.mocks.dart b/test/features/notifications/routine_notification_service_test.mocks.dart new file mode 100644 index 00000000..92a45510 --- /dev/null +++ b/test/features/notifications/routine_notification_service_test.mocks.dart @@ -0,0 +1,213 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in flutter_pecha/test/features/notifications/routine_notification_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:flutter_local_notifications/src/flutter_local_notifications_plugin.dart' + as _i2; +import 'package:flutter_local_notifications/src/initialization_settings.dart' + as _i4; +import 'package:flutter_local_notifications/src/notification_details.dart' + as _i6; +import 'package:flutter_local_notifications/src/platform_specifics/android/schedule_mode.dart' + as _i8; +import 'package:flutter_local_notifications/src/types.dart' as _i9; +import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart' + as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:timezone/timezone.dart' as _i7; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +/// A class which mocks [FlutterLocalNotificationsPlugin]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFlutterLocalNotificationsPlugin extends _i1.Mock + implements _i2.FlutterLocalNotificationsPlugin { + MockFlutterLocalNotificationsPlugin() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future initialize( + _i4.InitializationSettings? initializationSettings, { + _i5.DidReceiveNotificationResponseCallback? + onDidReceiveNotificationResponse, + _i5.DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }) => + (super.noSuchMethod( + Invocation.method( + #initialize, + [initializationSettings], + { + #onDidReceiveNotificationResponse: + onDidReceiveNotificationResponse, + #onDidReceiveBackgroundNotificationResponse: + onDidReceiveBackgroundNotificationResponse, + }, + ), + returnValue: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future<_i5.NotificationAppLaunchDetails?> + getNotificationAppLaunchDetails() => + (super.noSuchMethod( + Invocation.method(#getNotificationAppLaunchDetails, []), + returnValue: _i3.Future<_i5.NotificationAppLaunchDetails?>.value(), + ) + as _i3.Future<_i5.NotificationAppLaunchDetails?>); + + @override + _i3.Future show( + int? id, + String? title, + String? body, + _i6.NotificationDetails? notificationDetails, { + String? payload, + }) => + (super.noSuchMethod( + Invocation.method( + #show, + [id, title, body, notificationDetails], + {#payload: payload}, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future cancel(int? id, {String? tag}) => + (super.noSuchMethod( + Invocation.method(#cancel, [id], {#tag: tag}), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future cancelAll() => + (super.noSuchMethod( + Invocation.method(#cancelAll, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future cancelAllPendingNotifications() => + (super.noSuchMethod( + Invocation.method(#cancelAllPendingNotifications, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future zonedSchedule( + int? id, + String? title, + String? body, + _i7.TZDateTime? scheduledDate, + _i6.NotificationDetails? notificationDetails, { + required _i8.AndroidScheduleMode? androidScheduleMode, + String? payload, + _i9.DateTimeComponents? matchDateTimeComponents, + }) => + (super.noSuchMethod( + Invocation.method( + #zonedSchedule, + [id, title, body, scheduledDate, notificationDetails], + { + #androidScheduleMode: androidScheduleMode, + #payload: payload, + #matchDateTimeComponents: matchDateTimeComponents, + }, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future periodicallyShow( + int? id, + String? title, + String? body, + _i5.RepeatInterval? repeatInterval, + _i6.NotificationDetails? notificationDetails, { + required _i8.AndroidScheduleMode? androidScheduleMode, + String? payload, + }) => + (super.noSuchMethod( + Invocation.method( + #periodicallyShow, + [id, title, body, repeatInterval, notificationDetails], + {#androidScheduleMode: androidScheduleMode, #payload: payload}, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future periodicallyShowWithDuration( + int? id, + String? title, + String? body, + Duration? repeatDurationInterval, + _i6.NotificationDetails? notificationDetails, { + _i8.AndroidScheduleMode? androidScheduleMode = + _i8.AndroidScheduleMode.exact, + String? payload, + }) => + (super.noSuchMethod( + Invocation.method( + #periodicallyShowWithDuration, + [id, title, body, repeatDurationInterval, notificationDetails], + {#androidScheduleMode: androidScheduleMode, #payload: payload}, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future> + pendingNotificationRequests() => + (super.noSuchMethod( + Invocation.method(#pendingNotificationRequests, []), + returnValue: _i3.Future>.value( + <_i5.PendingNotificationRequest>[], + ), + ) + as _i3.Future>); + + @override + _i3.Future> getActiveNotifications() => + (super.noSuchMethod( + Invocation.method(#getActiveNotifications, []), + returnValue: _i3.Future>.value( + <_i5.ActiveNotification>[], + ), + ) + as _i3.Future>); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index a42764df..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:flutter_pecha/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}