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