Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Required to request exemption from battery optimization (keeps alarms alive when app is terminated) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Background audio permissions -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
Expand Down
47 changes: 46 additions & 1 deletion android/app/src/main/kotlin/org/pecha/app/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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<String>("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)
}
}
2 changes: 1 addition & 1 deletion android/app/src/main/res/raw/keep.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_notification,@drawable/launch_background" />
tools:keep="@drawable/ic_notification,@drawable/launch_background,@raw/routine" />
Binary file added android/app/src/main/res/raw/routine.ogg
Binary file not shown.
Binary file added assets/audios/routine.caf
Binary file not shown.
Binary file added assets/audios/routine.ogg
Binary file not shown.
42 changes: 21 additions & 21 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 = "<group>"; };
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 = "<group>"; };
F8F7BCF3F147DEA424FE966C /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
AA1EECC0F56A2B1C3D4E5F60 /* routine.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = routine.caf; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -201,6 +203,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
AA1EECC0F56A2B1C3D4E5F60 /* routine.caf */,
);
path = Runner;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
Binary file added ios/Runner/routine.caf
Binary file not shown.
8 changes: 8 additions & 0 deletions lib/core/config/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -277,6 +278,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
},
),

// notifications route
GoRoute(
path: AppRoutes.notifications,
name: "notifications",
builder: (context, state) => const NotificationSettingsScreen(),
),

// reader route - new refactored text reader
GoRoute(
path: "/reader/:textId",
Expand Down
2 changes: 1 addition & 1 deletion lib/core/config/router/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _protectedBasePaths = {
practiceEditRoutine, // Building routine requires auth
profile,
notifications,
plansInfo,
recitationDetail,
};
Expand Down
8 changes: 3 additions & 5 deletions lib/features/home/presentation/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
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');
Expand Down
13 changes: 13 additions & 0 deletions lib/features/more/presentation/more_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
);
}
11 changes: 11 additions & 0 deletions lib/features/notifications/data/models/notification_nav.dart
Original file line number Diff line number Diff line change
@@ -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<NotificationNav?>((ref) => null);
Loading