From 8fb853336179763cff2a0f03afd460a5f2accbfc Mon Sep 17 00:00:00 2001 From: Fareez Iqmal <60868965+iqfareez@users.noreply.github.com> Date: Sun, 31 Mar 2024 17:55:14 +0800 Subject: [PATCH] :zap: Enhance UX for azan notification (#230) * :zap: Change setting azan notification to alarmClock * :zap: Add schedule notification permission checker and requestor in Notification Setting Page * :pencil2: If notification is already granted, make able to open alarm setting * :sparkles: New notification permission onboarding screen * :sparkles: Add modal sheet on app startup to ask missing notification permissions * :sparkles: (Onboarding page) Skip permission dialog if not needed * :recycle: (Onboarding screen) Refactor out alert dialog components * :globe_with_meridians: Add localizations to permission dialogs, page, sheets, etc. --- lib/components/shake_widget.dart | 83 ------ .../exact_alarm_permission_off_sheet.dart | 61 ++++ .../notification_permission_off_sheet.dart | 61 ++++ .../components/autostart_setting_dialog.dart | 34 +++ ...ication_exact_alarm_permission_dialog.dart | 35 +++ .../notification_permission_dialog.dart | 25 ++ lib/l10n/app_en.arb | 19 +- lib/l10n/app_ms.arb | 19 +- lib/main.dart | 2 - .../notification_scheduler.dart | 2 +- .../notifications_helper.dart | 43 +-- lib/providers/autostart_warning_provider.dart | 12 - lib/views/app_body.dart | 73 ++++- lib/views/onboarding_page.dart | 274 +++++++++--------- .../settings/notification_page_setting.dart | 67 +++++ pubspec.lock | 48 +++ pubspec.yaml | 1 + 17 files changed, 588 insertions(+), 271 deletions(-) delete mode 100644 lib/components/shake_widget.dart create mode 100644 lib/features/home/views/components/exact_alarm_permission_off_sheet.dart create mode 100644 lib/features/home/views/components/notification_permission_off_sheet.dart create mode 100644 lib/features/onboarding/views/components/autostart_setting_dialog.dart create mode 100644 lib/features/onboarding/views/components/notification_exact_alarm_permission_dialog.dart create mode 100644 lib/features/onboarding/views/components/notification_permission_dialog.dart delete mode 100644 lib/providers/autostart_warning_provider.dart diff --git a/lib/components/shake_widget.dart b/lib/components/shake_widget.dart deleted file mode 100644 index d0fc630..0000000 --- a/lib/components/shake_widget.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -abstract class AnimationControllerState - extends State with SingleTickerProviderStateMixin { - AnimationControllerState(this.animationDuration); - final Duration animationDuration; - late final animationController = - AnimationController(vsync: this, duration: animationDuration); - - @override - void dispose() { - animationController.dispose(); - super.dispose(); - } -} - -/// Credit to https://mobikul.com/shake-effect-in-flutter/ -class ShakeWidget extends StatefulWidget { - const ShakeWidget({ - super.key, - required this.child, - required this.shakeOffset, - this.shakeCount = 3, - this.shakeDuration = const Duration(milliseconds: 400), - }); - final Widget child; - final double shakeOffset; - final int shakeCount; - final Duration shakeDuration; - - @override - // ignore: no_logic_in_create_state - State createState() => ShakeWidgetState(shakeDuration); -} - -class ShakeWidgetState extends AnimationControllerState { - ShakeWidgetState(super.duration); - - @override - void initState() { - super.initState(); - animationController.addStatusListener(_updateStatus); - } - - @override - void dispose() { - animationController.removeStatusListener(_updateStatus); - super.dispose(); - } - - void _updateStatus(AnimationStatus status) { - if (status == AnimationStatus.completed) { - animationController.reset(); - } - } - - void shake() { - animationController.forward(); - } - - @override - Widget build(BuildContext context) { - // 1. return an AnimatedBuilder - return AnimatedBuilder( - // 2. pass our custom animation as an argument - animation: animationController, - // 3. optimization: pass the given child as an argument - child: widget.child, - builder: (context, child) { - final sineValue = - sin(widget.shakeCount * 2 * pi * animationController.value); - return Transform.translate( - // 4. apply a translation as a function of the animation value - offset: Offset(sineValue * widget.shakeOffset, 0), - // 5. use the child widget - child: child, - ); - }, - ); - } -} diff --git a/lib/features/home/views/components/exact_alarm_permission_off_sheet.dart b/lib/features/home/views/components/exact_alarm_permission_off_sheet.dart new file mode 100644 index 0000000..3fdd2d8 --- /dev/null +++ b/lib/features/home/views/components/exact_alarm_permission_off_sheet.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ExactAlarmPermissionOffSheet extends StatelessWidget { + const ExactAlarmPermissionOffSheet({ + super.key, + required this.onGrantPermission, + required this.onCancelModal, + }); + + /// What will happen when "Give permission" button is pressed + final VoidCallback onGrantPermission; + + /// What will happen when "Cancel" button is pressed + final VoidCallback onCancelModal; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.all(24.0), + child: Icon(Icons.notifications_active_outlined, size: 80), + ), + const SizedBox(height: 8), + Text( + l10n.notifSheetExactAlarmTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.notifSheetExactAlarmDescription, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + onPressed: onGrantPermission, + child: Text(l10n.notifSheetExactAlarmPrimaryButton), + ), + TextButton( + onPressed: onCancelModal, + child: Text(l10n.notifSheetExactAlarmCancel), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/views/components/notification_permission_off_sheet.dart b/lib/features/home/views/components/notification_permission_off_sheet.dart new file mode 100644 index 0000000..3555047 --- /dev/null +++ b/lib/features/home/views/components/notification_permission_off_sheet.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class NotificationPermissionOffSheet extends StatelessWidget { + const NotificationPermissionOffSheet({ + super.key, + required this.onTurnOnNotification, + required this.onCancelModal, + }); + + /// What will happen when "Turn On Notification" button is pressed + final VoidCallback onTurnOnNotification; + + /// What will happen when "Keep it off for now" button is pressed + final VoidCallback onCancelModal; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.all(24.0), + child: Icon(Icons.notifications_off_outlined, size: 80), + ), + const SizedBox(height: 8), + Text( + l10n.notifSheetNotificationTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.notifSheetNotificationDescription, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + onPressed: onTurnOnNotification, + child: Text(l10n.notifSheetNotificationPrimaryButton), + ), + TextButton( + onPressed: onCancelModal, + child: Text(l10n.notifSheetNotificationCancel), + ), + ], + ), + ); + } +} diff --git a/lib/features/onboarding/views/components/autostart_setting_dialog.dart b/lib/features/onboarding/views/components/autostart_setting_dialog.dart new file mode 100644 index 0000000..58430ba --- /dev/null +++ b/lib/features/onboarding/views/components/autostart_setting_dialog.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class AutostartSettingDialog extends StatelessWidget { + const AutostartSettingDialog({ + super.key, + required this.leadingCount, + required this.onSkip, + required this.onGrantPermission, + }); + + final String leadingCount; + final VoidCallback onSkip; + final VoidCallback onGrantPermission; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(l10n.permissionDialogTitle), + content: Text('$leadingCount) ${l10n.autostartDialogPermissionContent}'), + actions: [ + TextButton( + onPressed: onSkip, + child: Text(l10n.permissionDialogSkip), + ), + TextButton( + onPressed: onGrantPermission, + child: Text(l10n.permissionDialogGrant), + ) + ], + ); + } +} diff --git a/lib/features/onboarding/views/components/notification_exact_alarm_permission_dialog.dart b/lib/features/onboarding/views/components/notification_exact_alarm_permission_dialog.dart new file mode 100644 index 0000000..782a92c --- /dev/null +++ b/lib/features/onboarding/views/components/notification_exact_alarm_permission_dialog.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class NotificationExactAlarmPermissionDialog extends StatelessWidget { + const NotificationExactAlarmPermissionDialog({ + super.key, + required this.leadingCount, + required this.onSkip, + required this.onGrantPermission, + }); + + final String leadingCount; + final VoidCallback onSkip; + final VoidCallback onGrantPermission; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(l10n.permissionDialogTitle), + content: + Text('$leadingCount) ${l10n.notifExactAlarmDialogPermissionContent}'), + actions: [ + TextButton( + onPressed: onSkip, + child: Text(l10n.permissionDialogSkip), + ), + TextButton( + onPressed: onGrantPermission, + child: Text(l10n.permissionDialogGrant), + ) + ], + ); + } +} diff --git a/lib/features/onboarding/views/components/notification_permission_dialog.dart b/lib/features/onboarding/views/components/notification_permission_dialog.dart new file mode 100644 index 0000000..e7d308c --- /dev/null +++ b/lib/features/onboarding/views/components/notification_permission_dialog.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class NotificationPermissionDialog extends StatelessWidget { + const NotificationPermissionDialog( + {super.key, required this.leadingCount, required this.onGrantPermission}); + + final String leadingCount; + final VoidCallback onGrantPermission; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(l10n.permissionDialogTitle), + content: Text('$leadingCount) ${l10n.notifDialogPermissionContent}'), + actions: [ + TextButton( + onPressed: onGrantPermission, + child: Text(l10n.permissionDialogGrant), + ) + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f86420e..c957297 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -225,7 +225,7 @@ "onboardingLocationSet": "Set location", "onboardingThemeFav": "Set your favourite theme", "onboardingNotifOption": "Select notification preferences", - "onboardingNotifDefault": "Default notification sound", + "onboardingNotifDefault": "Default notification", "onboardingNotifAzan": "Azan (full)", "onboardingNotifShortAzan": "Azan (short)", "onboardingNotifAutostart": "**Autostart** need to be enabled for the app to send notifications. [Learn more...]({link})", @@ -241,6 +241,12 @@ "onboardingFinishDesc": "Welcome aboard. Do explore other features and tweak other settings as well.", "onboardingDone": "Done", "onboardingNext": "Next", + "permissionDialogTitle": "Permission required", + "permissionDialogSkip": "Skip", + "permissionDialogGrant": "Grant", + "autostartDialogPermissionContent": "Please allow app to Autostart to keep receive notifications even if the device restarts", + "notifDialogPermissionContent": "Please grant the notification permission to allow this app to show notifications", + "notifExactAlarmDialogPermissionContent": "Please grant the app permission to schedule notifications at exact time", "zoneUpdatedToast": "Location updated", "zoneYourLocation": "Your location", "zoneSetManually": "Set manually", @@ -277,6 +283,17 @@ "notifSettingCancel": "Cancel", "notifSettingProceed": "Proceed", "notifSettingNotifDemo": "This is how the notification/azan will sound like", + "notifSettingsExactAlarmPermissionTitle": "Notification scheduling permission", + "notifSettingsExactAlarmPermissionGrantedSubtitle": "Permission granted. The app can send azan notification on prayer times", + "notifSettingsExactAlarmPermissionNotGrantedSubtitle": "Permission not granted. The app cannot send the azan notification. Tap here to grant permission", + "notifSheetExactAlarmTitle": "We require one more permission to trigger the notification/azan at the right time", + "notifSheetExactAlarmDescription": "This permission is needed to push the notification at the correct time. If you say no, the app might still schedule the notification, but the delivery may be delayed.", + "notifSheetExactAlarmPrimaryButton": "Grant now", + "notifSheetExactAlarmCancel": "Cancel", + "notifSheetNotificationTitle": "Notification/Azan is turned off", + "notifSheetNotificationDescription": "We may require some permissions to be able to play the notification/azan", + "notifSheetNotificationPrimaryButton": "Turn On Notification'", + "notifSheetNotificationCancel": "Keep it off for now", "contributenDesc": "Alhamdulillah. If you love using our app and would like to show your appreciation, you can now make a financial contribution to support our ongoing efforts. May Allah SWT rewards your kindness.", "contributeShare": "Share this app", "contributeShareDesc": "Share your experience of using this app with your family and friends.", diff --git a/lib/l10n/app_ms.arb b/lib/l10n/app_ms.arb index 8379f76..6d58af8 100644 --- a/lib/l10n/app_ms.arb +++ b/lib/l10n/app_ms.arb @@ -136,7 +136,7 @@ "onboardingLocationSet": "Tetapkan lokasi", "onboardingThemeFav": "Tetapkan tema kegemaran anda", "onboardingNotifOption": "Pilih jenis pemberitahuan", - "onboardingNotifDefault": "Bunyi pemberitahuan lalai", + "onboardingNotifDefault": "Pemberitahuan lalai", "onboardingNotifAzan": "Azan (penuh)", "onboardingNotifShortAzan": "Azan (pendek)", "onboardingNotifAutostart": "**Autostart** perlu dihidupkan untuk apl menghantar pemberitahuan. [Ketahui lebih lanjut..]({link})", @@ -145,6 +145,12 @@ "onboardingFinishDesc": "Selamat datang. Terokai ciri dan ubah suai tetapan lain mengikut citarasa anda.", "onboardingDone": "Selesai", "onboardingNext": "Seterusnya", + "permissionDialogTitle": "Kebenaran Diperlukan", + "permissionDialogSkip": "Langkau", + "permissionDialogGrant": "Berikan", + "autostartDialogPermissionContent": "Sila benarkan aplikasi untuk Autostart agar dapat menerima pemberitahuan walaupun peranti dihidupkan semula", + "notifDialogPermissionContent": "Sila berikan kebenaran pemberitahuan untuk membolehkan aplikasi ini menunjukkan pemberitahuan", + "notifExactAlarmDialogPermissionContent": "Sila berikan kebenaran aplikasi untuk menjadual pemberitahuan pada masa yang tepat", "zoneUpdatedToast": "Lokasi dikemaskini", "zoneYourLocation": "Lokasi anda", "zoneSetManually": "Pilih sendiri", @@ -174,6 +180,17 @@ "notifSettingCancel": "Batal", "notifSettingProceed": "Teruskan", "notifSettingNotifDemo": "Notifikasi/azan akan berbunyi seperti ini", + "notifSettingsExactAlarmPermissionTitle": "Kebenaran Penjadualan Pemberitahuan", + "notifSettingsExactAlarmPermissionGrantedSubtitle": "Kebenaran diberikan. Aplikasi boleh menghantar pemberitahuan azan pada waktu solat", + "notifSettingsExactAlarmPermissionNotGrantedSubtitle": "Kebenaran tidak diberikan. Aplikasi tidak dapat menghantar pemberitahuan azan. Ketik di sini untuk memberikan kebenaran", + "notifSheetExactAlarmTitle": "Kami perlukan satu lagi kebenaran untuk memulakan pemberitahuan/azan pada masa yang betul", + "notifSheetExactAlarmDescription": "Kebenaran ini diperlukan untuk menghantar pemberitahuan pada masa yang betul. Jika anda menolak, aplikasi mungkin masih menjadual pemberitahuan, tetapi penghantaran mungkin terlewat.", + "notifSheetExactAlarmPrimaryButton": "Berikan sekarang", + "notifSheetExactAlarmCancel": "Batal", + "notifSheetNotificationTitle": "Pemberitahuan/Azan dimatikan", + "notifSheetNotificationDescription": "Kami mungkin memerlukan beberapa kebenaran untuk dapat memainkan pemberitahuan/azan", + "notifSheetNotificationPrimaryButton": "Hidupkan Pemberitahuan", + "notifSheetNotificationCancel": "Biar dimatikan untuk masa ini", "contributenDesc": "Alhamdulillah. Saya menghargai niat anda untuk menderma ke aplikasi Waktu Solat Malaysia. Semoga Allah SWT membalas jasa baik kalian.", "contributeShare": "Kongsi aplikasi ini", "contributeShareDesc": "Kongsi pengalaman anda menggunakan aplikasi ini dengan keluarga dan rakan anda.", diff --git a/lib/main.dart b/lib/main.dart index dd0208a..db02942 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,6 @@ import 'firebase_options.dart'; import 'location_utils/location_database.dart'; import 'models/jakim_zones.dart'; import 'notificationUtil/notifications_helper.dart'; -import 'providers/autostart_warning_provider.dart'; import 'providers/locale_provider.dart'; import 'providers/location_provider.dart'; import 'providers/setting_provider.dart'; @@ -103,7 +102,6 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => UpdaterProvider()), ChangeNotifierProvider(create: (_) => LocaleProvider()), ChangeNotifierProvider(create: (_) => TimetableProvider()), - ChangeNotifierProvider(create: (_) => AutostartWarningProvider()), ], child: Consumer2( builder: (_, themeValue, localeValue, __) { diff --git a/lib/notificationUtil/notification_scheduler.dart b/lib/notificationUtil/notification_scheduler.dart index e2e4dc2..73e855c 100644 --- a/lib/notificationUtil/notification_scheduler.dart +++ b/lib/notificationUtil/notification_scheduler.dart @@ -11,7 +11,7 @@ import '../views/settings/notification_page_setting.dart'; import 'notifications_helper.dart'; class MyNotifScheduler { - /// Check if app can schedule notification on Android 13+ (S+) + /// Check if app can schedule notification on Android 13+ (S+). Always return true on Android 12 and below. static Future _canScheduleNotification() async { final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); diff --git a/lib/notificationUtil/notifications_helper.dart b/lib/notificationUtil/notifications_helper.dart index 0c07335..0ff0204 100644 --- a/lib/notificationUtil/notifications_helper.dart +++ b/lib/notificationUtil/notifications_helper.dart @@ -14,8 +14,7 @@ class NotificationClass { } Future initNotifications() async { - const initializationSettingsAndroid = - AndroidInitializationSettings('icon'); + const initializationSettingsAndroid = AndroidInitializationSettings('icon'); const initializationSettings = InitializationSettings(android: initializationSettingsAndroid); @@ -44,26 +43,27 @@ Future scheduleSinglePrayerNotification({ when: scheduledTime.millisecondsSinceEpoch, color: const Color(0xFF19e3cb), ); - final platformChannelSpecifics = NotificationDetails(android: androidSpecifics); + final platformChannelSpecifics = + NotificationDetails(android: androidSpecifics); await FlutterLocalNotificationsPlugin().zonedSchedule( id, title, body, scheduledTime, platformChannelSpecifics, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: AndroidScheduleMode.alarmClock, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation .absoluteTime); // This literally schedules the notification } /// Single prayer azan notification -Future scheduleSingleAzanNotification( - //for main prayer functionality - {required String name, - required int id, - required String title, - required String body, - required TZDateTime scheduledTime, - required String customSound, - String? summary}) async { +Future scheduleSingleAzanNotification({ + required String name, + required int id, + required String title, + required String body, + required TZDateTime scheduledTime, + required String customSound, + String? summary, +}) async { final BigTextStyleInformation styleInformation = BigTextStyleInformation(body, summaryText: summary); final androidSpecifics = AndroidNotificationDetails( @@ -83,11 +83,12 @@ Future scheduleSingleAzanNotification( sound: RawResourceAndroidNotificationSound(customSound), color: const Color(0xFF19e3cb), ); - final platformChannelSpecifics = NotificationDetails(android: androidSpecifics); + final platformChannelSpecifics = + NotificationDetails(android: androidSpecifics); await FlutterLocalNotificationsPlugin().zonedSchedule( id, title, body, scheduledTime, platformChannelSpecifics, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: AndroidScheduleMode.alarmClock, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation .absoluteTime); // This literally schedules the notification @@ -100,7 +101,8 @@ Future scheduleAlertNotification( required String body, String? payload, required TZDateTime scheduledTime}) async { - final BigTextStyleInformation styleInformation = BigTextStyleInformation(body); + final BigTextStyleInformation styleInformation = + BigTextStyleInformation(body); final androidSpecifics = AndroidNotificationDetails( 'Alert id', 'Alert notification', @@ -112,7 +114,8 @@ Future scheduleAlertNotification( color: const Color(0xFFfcbd00), ); - final platformChannelSpecifics = NotificationDetails(android: androidSpecifics); + final platformChannelSpecifics = + NotificationDetails(android: androidSpecifics); await FlutterLocalNotificationsPlugin().zonedSchedule( id, title, body, scheduledTime, platformChannelSpecifics, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, @@ -152,7 +155,8 @@ Future fireDefaultNotification({ category: AndroidNotificationCategory.alarm, color: Color.fromARGB(255, 251, 53, 172), ); - const platformChannelSpecifics = NotificationDetails(android: androidSpecifics); + const platformChannelSpecifics = + NotificationDetails(android: androidSpecifics); await FlutterLocalNotificationsPlugin().show( 344, // just random id @@ -181,7 +185,8 @@ Future fireAzanNotification({ : 'azan_kurdhi2010'), color: const Color.fromARGB(255, 251, 53, 172), ); - final platformChannelSpecifics = NotificationDetails(android: androidSpecifics); + final platformChannelSpecifics = + NotificationDetails(android: androidSpecifics); await FlutterLocalNotificationsPlugin().show( MyNotificationType.shortAzan.index * 2, diff --git a/lib/providers/autostart_warning_provider.dart b/lib/providers/autostart_warning_provider.dart deleted file mode 100644 index eddf5ac..0000000 --- a/lib/providers/autostart_warning_provider.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -class AutostartWarningProvider extends ChangeNotifier { - bool _hasClickOpen = false; - - bool get hasClick => _hasClickOpen; - - set hasClick(bool hasClickOpen) { - _hasClickOpen = hasClickOpen; - notifyListeners(); - } -} diff --git a/lib/views/app_body.dart b/lib/views/app_body.dart index 1b086f0..93bf353 100644 --- a/lib/views/app_body.dart +++ b/lib/views/app_body.dart @@ -2,16 +2,19 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get_storage/get_storage.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import '../constants.dart'; +import '../features/home/views/components/exact_alarm_permission_off_sheet.dart'; +import '../features/home/views/components/notification_permission_off_sheet.dart'; import '../location_utils/location_database.dart'; import '../providers/location_provider.dart'; import '../providers/updater_provider.dart'; @@ -37,7 +40,7 @@ class _AppBodyState extends State { _checkForUpdate(); _showUpdateNotesAndNotFirstRun(); - _requestScheduleNotificationPermission(); + _promptScheduleNotificationPermission(); } void _checkForUpdate() async { @@ -73,19 +76,63 @@ class _AppBodyState extends State { /// Request schedule notification permission. The permission already requested from the onboarding page, /// but for users that have their system upgraded, the permission will be requested here. - void _requestScheduleNotificationPermission() async { - debugPrint('Requesting notification permission'); - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - final androidNotif = - flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - final permissionGranted = - await androidNotif?.canScheduleExactNotifications() ?? false; + void _promptScheduleNotificationPermission() async { + // First, check is notification is enabled at all + final isNotificationGranted = await Permission.notification.status; + debugPrint('Notification permission status: $isNotificationGranted'); - if (!permissionGranted) { - androidNotif?.requestNotificationsPermission(); + if (!isNotificationGranted.isGranted) { + final PermissionStatus? status = await showModalBottomSheet( + context: context, + builder: (_) { + return NotificationPermissionOffSheet( + onTurnOnNotification: () async { + final res = await Permission.notification.request(); + Navigator.pop(context, res); + }, + onCancelModal: () { + Navigator.pop(context); + }, + ); + }, + ); + + if (status != PermissionStatus.granted) { + // TODO: Show toast to tell this setting can be found in the settings + return; // Will not trigger the next checking for exact alarm permission + } } + + final isScheduleExactAlarmGranted = + await Permission.scheduleExactAlarm.status; + debugPrint( + 'Schedule exact alarm permission status: $isScheduleExactAlarmGranted'); + + if (isScheduleExactAlarmGranted.isGranted) return; + + final PermissionStatus? exactAlarmStatus = await showModalBottomSheet( + context: context, + builder: (_) { + return ExactAlarmPermissionOffSheet( + onGrantPermission: () async { + final res = await Permission.scheduleExactAlarm.request(); + Navigator.pop(context, res); + }, + onCancelModal: () { + Navigator.pop(context); + // TODO: Show toast to tell this setting can be found in the settings + }, + ); + }, + ); + + if (exactAlarmStatus != PermissionStatus.granted) { + // TODO: Show another modal says to open settings later + return; + } + + Fluttertoast.showToast( + msg: 'Thank you for granting the permissions needed'); } @override diff --git a/lib/views/onboarding_page.dart b/lib/views/onboarding_page.dart index e2d95fa..0393b68 100644 --- a/lib/views/onboarding_page.dart +++ b/lib/views/onboarding_page.dart @@ -1,22 +1,21 @@ import 'package:auto_start_flutter/auto_start_flutter.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:get_storage/get_storage.dart'; import 'package:introduction_screen/introduction_screen.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import '../constants.dart'; +import '../features/onboarding/views/components/autostart_setting_dialog.dart'; +import '../features/onboarding/views/components/notification_exact_alarm_permission_dialog.dart'; +import '../features/onboarding/views/components/notification_permission_dialog.dart'; import '../main.dart'; -import '../providers/autostart_warning_provider.dart'; import '../providers/locale_provider.dart'; -import '../utils/launch_url.dart'; import 'settings/notification_page_setting.dart'; import 'settings/theme_page.dart'; -import '../components/shake_widget.dart'; import 'zone_chooser.dart'; class OnboardingPage extends StatefulWidget { @@ -29,7 +28,6 @@ class _OnboardingPageState extends State with SingleTickerProviderStateMixin { final GlobalKey _introScreenKey = GlobalKey(); - final shakeKey = GlobalKey(); final _pageDecoration = const PageDecoration( titleTextStyle: TextStyle(fontSize: 28.0, fontWeight: FontWeight.w700), bodyTextStyle: TextStyle(fontSize: 19.0), @@ -39,7 +37,10 @@ class _OnboardingPageState extends State ); bool _isDoneSetLocation = false; + bool _isDoneSetPermission = false; AnimationController? _animController; + + // default to azan type. As defined in initGetStorage() in main.dart MyNotificationType _notificationType = MyNotificationType.values.elementAt(GetStorage().read(kNotificationType)); @@ -68,6 +69,91 @@ class _OnboardingPageState extends State return autoStartAvailable ?? false; } + /// Request necessary permissions for notification + Future _requestNecessaryNotificationPermissions() async { + // Check notification status. Prior Android 13, the notification is grancted by default + final isNotificationGranted = await Permission.notification.status; + + // check if Autostart is available on this device + final bool isAutostartAvailable = await checkAutoStart(); + + // check if schedule exact alarm permission is granted + final isScheduleAlarmGranted = await Permission.scheduleExactAlarm.status; + + int permissionCount = 1; // to number the permission dialog + if (isNotificationGranted != PermissionStatus.granted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return NotificationPermissionDialog( + leadingCount: permissionCount.toString(), + onGrantPermission: () async { + final FlutterLocalNotificationsPlugin + flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + final perm1 = await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + debugPrint('Notification permission: $perm1'); + + Navigator.pop(context); + }, + ); + }, + ); + } + permissionCount++; + if (isScheduleAlarmGranted != PermissionStatus.granted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return NotificationExactAlarmPermissionDialog( + leadingCount: permissionCount.toString(), + onSkip: () { + Navigator.of(context).pop(); + }, + onGrantPermission: () async { + final FlutterLocalNotificationsPlugin + flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + // requst permission to schedule exact alarms (API 33+) + final perm2 = await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestExactAlarmsPermission(); + + debugPrint('Schedule Exact Notification permission: $perm2'); + + Navigator.pop(context); + }, + ); + }, + ); + permissionCount++; + } + if (isAutostartAvailable) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return AutostartSettingDialog( + leadingCount: permissionCount.toString(), + onSkip: () => Navigator.pop(context), + onGrantPermission: () async { + await getAutoStartPermission(); + + Navigator.pop(context); + }, + ); + }, + ); + } + _isDoneSetPermission = true; + } + @override Widget build(BuildContext context) { final List pages = [ @@ -99,41 +185,43 @@ class _OnboardingPageState extends State }), ), PageViewModel( - title: AppLocalizations.of(context)!.onboardingSetLocation, - image: Image.asset('assets/3d/Pin.png', width: 200), - decoration: _pageDecoration, - bodyWidget: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - AppLocalizations.of(context)!.onboardingLocationDesc, - textAlign: TextAlign.center, - ), - const SizedBox(height: 71), - _isDoneSetLocation - ? Text( - AppLocalizations.of(context)!.onboardingLocationToast, - textAlign: TextAlign.center, - ) - : ElevatedButton( - onPressed: () async { - final res = - await LocationChooser.showLocationChooser(context); - if (res) { - setState(() => _isDoneSetLocation = true); - } - }, - child: Text( - AppLocalizations.of(context)!.onboardingLocationSet), - ), - ], - )), + title: AppLocalizations.of(context)!.onboardingSetLocation, + image: Image.asset('assets/3d/Pin.png', width: 200), + decoration: _pageDecoration, + bodyWidget: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + AppLocalizations.of(context)!.onboardingLocationDesc, + textAlign: TextAlign.center, + ), + const SizedBox(height: 71), + _isDoneSetLocation + ? Text( + AppLocalizations.of(context)!.onboardingLocationToast, + textAlign: TextAlign.center, + ) + : ElevatedButton( + onPressed: () async { + final res = + await LocationChooser.showLocationChooser(context); + if (res) { + setState(() => _isDoneSetLocation = true); + } + }, + child: Text( + AppLocalizations.of(context)!.onboardingLocationSet), + ), + ], + ), + ), PageViewModel( image: Padding( padding: const EdgeInsets.all(50.0), child: Builder( builder: (_) { - final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; + final bool isDarkMode = + Theme.of(context).brightness == Brightness.dark; if (isDarkMode) { _animController!.forward(); } else { @@ -181,22 +269,6 @@ class _OnboardingPageState extends State GetStorage().write(kNotificationType, type?.index); setState(() => _notificationType = type!); }), - FutureBuilder( - future: checkAutoStart(), - builder: (_, snapshot) { - if (snapshot.hasData && snapshot.data!) { - // https://mobikul.com/shake-effect-in-flutter/ - return ShakeWidget( - key: shakeKey, - shakeOffset: 10, - shakeCount: 5, - shakeDuration: const Duration(milliseconds: 200), - child: const _AutostartAdmonition(), - ); - } - - return const SizedBox.shrink(); - }) ]), ), PageViewModel( @@ -219,51 +291,18 @@ class _OnboardingPageState extends State activeShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5.0)), ), - onChange: (value) async { - // show notification permission dialog - // when user reached the notification style chooser - if (value == 3) { - /// ask NotificationPermission - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - final perm1 = await flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() - ?.requestNotificationsPermission(); - // requst permission to schedule exact alarms (API 33+) - final perm2 = await flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() - ?.requestExactAlarmsPermission(); - - debugPrint('perm1: $perm1, perm2: $perm2'); - } - }, overrideNext: TextButton( - child: Text(AppLocalizations.of(context)!.onboardingNext, - style: const TextStyle(fontWeight: FontWeight.w600)), - onPressed: () async { - final bool hasClick = - Provider.of(context, listen: false) - .hasClick; - final bool isAutostartAvailable = await checkAutoStart(); - - if (!isAutostartAvailable) { - _introScreenKey.currentState?.next(); - return; - } - - if (_introScreenKey.currentState!.controller.page!.toInt() == 3 && - !hasClick) { - // if the open setting button hasn't been clicked - // shake the widgeta and wibrate a lil - shakeKey.currentState?.shake(); - HapticFeedback.mediumImpact(); - } else { - // using internal controller to go next - _introScreenKey.currentState?.next(); - } - }), + child: Text(AppLocalizations.of(context)!.onboardingNext, + style: const TextStyle(fontWeight: FontWeight.w600)), + onPressed: () async { + /// Request necessary permissions for notification when transitioning to page 3 to final page + if (!_isDoneSetPermission && + _introScreenKey.currentState!.controller.page!.toInt() == 3) { + await _requestNecessaryNotificationPermissions(); + } + _introScreenKey.currentState?.next(); + }, + ), done: Text(AppLocalizations.of(context)!.onboardingDone, style: const TextStyle(fontWeight: FontWeight.w600)), doneSemantic: "Done button", @@ -276,46 +315,3 @@ class _OnboardingPageState extends State }); } } - -class _AutostartAdmonition extends StatelessWidget { - const _AutostartAdmonition(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 2), - margin: const EdgeInsets.only(top: 15), - decoration: BoxDecoration( - color: Colors.amber.withOpacity(.3), - borderRadius: BorderRadius.circular(16)), - child: Column( - children: [ - MarkdownBody( - data: AppLocalizations.of(context)!.onboardingNotifAutostart( - '$kAppSupportWebsite/docs/troubleshoot/notifications'), - onTapLink: (_, href, __) { - LaunchUrl.normalLaunchUrl(url: href!); - }, - ), - Align( - alignment: Alignment.topRight, - child: TextButton( - style: TextButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: - Theme.of(context).textTheme.bodyLarge!.color), - onPressed: () { - // to track the state of click - Provider.of(context, listen: false) - .hasClick = true; - // open auto start setting - getAutoStartPermission(); - }, - child: Text(AppLocalizations.of(context)! - .onboardingNotifAutostartSetting)), - ), - ], - ), - ); - } -} diff --git a/lib/views/settings/notification_page_setting.dart b/lib/views/settings/notification_page_setting.dart index 037b4a5..1df7232 100644 --- a/lib/views/settings/notification_page_setting.dart +++ b/lib/views/settings/notification_page_setting.dart @@ -33,6 +33,40 @@ class _NotificationPageSettingState extends State { ScaffoldFeatureController? _bannerController; + /// Copied from lib/notificationUtil/notification_scheduler.dart + Future _canScheduleNotification() async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + final androidNotif = + flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + // underlying implementation: https://github.com/MaikuB/flutter_local_notifications/blob/ca71c96ba2a245175b44471e2e41e4958d480876/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java#L2119 + final res = await androidNotif?.canScheduleExactNotifications(); + return res ?? false; + } + + /// Request permission kalau belum dapat permission. Kalau dah dapat, just + /// open the relevant setting page + void _requestOrOpenAlarmPermission() async { + // check dulu permissionnya + final dahAdaPermissionScheduleAlarm = await _canScheduleNotification(); + + if (dahAdaPermissionScheduleAlarm) { + AppSettings.openAppSettings(type: AppSettingsType.alarm); + return; + } + + // kalau belum, request permission + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + final scheduleExactAlarmPermission = await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestExactAlarmsPermission(); + + debugPrint('scheduleExactAlarmPermission: $scheduleExactAlarmPermission'); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -167,6 +201,39 @@ class _NotificationPageSettingState extends State { )), ), ), + FutureBuilder( + future: _canScheduleNotification(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const SizedBox.shrink(); + if (snapshot.data ?? false) { + return Card( + clipBehavior: Clip.hardEdge, + child: ListTile( + onTap: _requestOrOpenAlarmPermission, + title: Text(AppLocalizations.of(context)! + .notifSettingsExactAlarmPermissionTitle), + subtitle: Text(AppLocalizations.of(context)! + .notifSettingsExactAlarmPermissionGrantedSubtitle), + ), + ); + } else { + // If not granted, highlight this option in yellow to draw user attention + return Card( + color: Theme.of(context).brightness == Brightness.light + ? Colors.yellow[100] + : Colors.yellow.withAlpha(60), + clipBehavior: Clip.hardEdge, + child: ListTile( + title: Text(AppLocalizations.of(context)! + .notifSettingsExactAlarmPermissionTitle), + isThreeLine: true, + subtitle: Text(AppLocalizations.of(context)! + .notifSettingsExactAlarmPermissionNotGrantedSubtitle), + onTap: _requestOrOpenAlarmPermission, + ), + ); + } + }), Padding( padding: const EdgeInsets.all(8.0), child: Text(AppLocalizations.of(context)! diff --git a/pubspec.lock b/pubspec.lock index 1403bb1..da3234e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -799,6 +799,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" + url: "https://pub.dev" + source: hosted + version: "12.0.5" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 + url: "https://pub.dev" + source: hosted + version: "9.4.4" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7719522..956fb3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: path_provider: ^2.1.1 open_file: ^3.3.2 home_widget: ^0.4.1 + permission_handler: ^11.3.1 dependency_overrides: # flutter compass in pub.dev is not updated