From 1608712ca8d73002d6b845853848afbc4db2f908 Mon Sep 17 00:00:00 2001 From: Ghassen Ben Zahra Date: Fri, 25 Oct 2024 14:39:07 +0100 Subject: [PATCH] schedule listening feature --- lib/l10n/intl_en.arb | 15 +- lib/l10n/intl_fr.arb | 15 +- lib/src/const/constants.dart | 17 + .../quran/page/reciter_selection_screen.dart | 116 +++++-- lib/src/pages/quran/page/schedule_screen.dart | 309 +++++++++++++++++ .../androidtv_timepicker.dart | 91 +++++ .../custom_overlay_notification.dart | 45 +++ .../custom_scheduling_drop_down.dart | 46 +++ .../focusable_timepicker.dart | 64 ++++ .../background_audio_schedule_service.dart | 318 +++++++++++++++++ .../audio_control_notifier.dart | 179 ++++++++++ .../audio_control_state.dart | 35 ++ .../schedule_listening_notifier.dart | 325 ++++++++++++++++++ .../schedule_listening_state.dart | 68 ++++ pubspec.yaml | 3 +- 15 files changed, 1623 insertions(+), 23 deletions(-) create mode 100644 lib/src/pages/quran/page/schedule_screen.dart create mode 100644 lib/src/pages/quran/widget/schedule_screen_widgets/androidtv_timepicker.dart create mode 100644 lib/src/pages/quran/widget/schedule_screen_widgets/custom_overlay_notification.dart create mode 100644 lib/src/pages/quran/widget/schedule_screen_widgets/custom_scheduling_drop_down.dart create mode 100644 lib/src/pages/quran/widget/schedule_screen_widgets/focusable_timepicker.dart create mode 100644 lib/src/services/background_audio_schedule_service.dart create mode 100644 lib/src/state_management/quran/schedule_listening/audio_control_notifier.dart create mode 100644 lib/src/state_management/quran/schedule_listening/audio_control_state.dart create mode 100644 lib/src/state_management/quran/schedule_listening/schedule_listening_notifier.dart create mode 100644 lib/src/state_management/quran/schedule_listening/schedule_listening_state.dart diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 108d0ddb..248b00b6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -352,5 +352,18 @@ } } }, - "surahSelector":"Select Surah" + "surahSelector":"Select Surah", + "scheduleSaved": "Your schedule has been saved.", + "completeAllFields": "Please complete all fields before saving.", + "endTimeAfter": "End time must be later than the start time.", + "scheduleListening": "Scheduled Listening", + "enableScheduling": "Enable Scheduling", + "scheduleDesc": "Activate this feature to automatically play Surah at scheduled times.", + "startTime": "Start Time", + "endTime": "End Time", + "selectReciter": "Choose a Reciter", + "selectMoshaf": "Choose Moshaf", + "randomSurahSelection": "Random Surah Selection", + "selectSurah": "Choose Surah", + "save": "Save" } diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 5e96d18d..9b7247e5 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -352,6 +352,19 @@ "example": "604" } } - } + }, + "scheduleSaved": "Votre planification a été enregistré.", + "completeAllFields": "Veuillez remplir tous les champs avant de sauvegarder.", + "endTimeAfter": "L'heure de fin doit être postérieure à l'heure de début.", + "scheduleListening": "Écoute planifiée", + "enableScheduling": "Activer la planification", + "scheduleDesc": "Activez cette fonctionnalité pour lire automatiquement une Sourate aux heures planifiées.", + "startTime": "Heure de Début", + "endTime": "Heure de Fin", + "selectReciter": "Choisissez un Récitateur", + "selectMoshaf": "Choisissez un Moushaf", + "randomSurahSelection": "Sélection Aléatoire de Sourate", + "selectSurah": "Choisissez une Sourate", + "save": "Sauvegarder" } diff --git a/lib/src/const/constants.dart b/lib/src/const/constants.dart index 14499c31..b9bde5e2 100644 --- a/lib/src/const/constants.dart +++ b/lib/src/const/constants.dart @@ -96,3 +96,20 @@ abstract class SystemFeaturesConstant { static const String kHdmi = 'android.hardware.hdmi'; static const String kEthernet = 'android.hardware.ethernet'; } +class BackgroundScheduleAudioServiceConstant { + static const String kManualPause = 'manual_pause_enabled'; + static const String kPendingSchedule = 'pending_schedule'; + static const String kScheduleEnabled = 'schedule_enabled'; + static const String kStartTime = 'start_time'; + static const String kEndTime = 'end_time'; + static const String kRandomEnabled = 'isRandomEnabled'; + static const String kRandomUrls = 'random_urls'; + static const String kSelectedSurah = 'selected_surah'; + static const String kSelectedSurahUrl = 'selected_surah_url'; + static const String kSelectedReciter = 'selected_reciter'; + static const String kSelectedMoshaf = 'selected_moshaf'; + static const String kAudioStateChanged = 'kAudioStateChanged'; + static const String kGetPlaybackState = 'kGetPlaybackState'; + static const String kStopAudio = 'kStopAudio'; + static const String kResumeAudio = 'kResumeAudio'; +} diff --git a/lib/src/pages/quran/page/reciter_selection_screen.dart b/lib/src/pages/quran/page/reciter_selection_screen.dart index 7b25b32f..7a6bb5a4 100644 --- a/lib/src/pages/quran/page/reciter_selection_screen.dart +++ b/lib/src/pages/quran/page/reciter_selection_screen.dart @@ -10,12 +10,15 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:mawaqit/const/resource.dart'; import 'package:mawaqit/src/helpers/RelativeSizes.dart'; import 'package:mawaqit/src/pages/quran/page/quran_reading_screen.dart'; +import 'package:mawaqit/src/pages/quran/page/schedule_screen.dart'; import 'package:mawaqit/src/pages/quran/widget/recite_type_grid_view.dart'; import 'package:mawaqit/src/services/theme_manager.dart'; import 'package:mawaqit/src/state_management/quran/quran/quran_notifier.dart'; import 'package:mawaqit/src/state_management/quran/quran/quran_state.dart'; import 'package:mawaqit/src/state_management/quran/recite/recite_notifier.dart'; import 'package:mawaqit/src/state_management/quran/recite/recite_state.dart'; +import 'package:mawaqit/src/state_management/quran/schedule_listening/audio_control_notifier.dart'; +import 'package:mawaqit/src/state_management/quran/schedule_listening/audio_control_state.dart'; import 'package:shimmer/shimmer.dart'; import 'package:sizer/sizer.dart'; import 'package:mawaqit/i18n/l10n.dart'; @@ -44,6 +47,7 @@ class _ReciterSelectionScreenState extends ConsumerState late FocusNode favoriteFocusNode; late FocusNode changeReadingModeFocusNode; + late FocusNode scheduleListeningFocusNode; late FocusScopeNode reciteTypeFocusScopeNode; late FocusScopeNode reciteFocusScopeNode; @@ -77,7 +81,9 @@ class _ReciterSelectionScreenState extends ConsumerState } } }); + changeReadingModeFocusNode = FocusNode(debugLabel: 'change_reading_mode_focus_node'); + scheduleListeningFocusNode = FocusNode(debugLabel: 'scheduleListeningFocusNode'); favoriteFocusNode = FocusNode(debugLabel: 'favorite_focus_node'); reciteTypeFocusScopeNode = FocusScopeNode(debugLabel: 'reciter_type_focus_scope_node'); @@ -97,6 +103,7 @@ class _ReciterSelectionScreenState extends ConsumerState reciteTypeFocusScopeNode.dispose(); reciteFocusScopeNode.dispose(); + scheduleListeningFocusNode.dispose(); _tabController.dispose(); _searchController.dispose(); @@ -110,30 +117,99 @@ class _ReciterSelectionScreenState extends ConsumerState @override Widget build(BuildContext context) { + final audioState = ref.watch(audioControlProvider); + return Scaffold( key: _scaffoldKey, - floatingActionButton: SizedBox( - width: 40.sp, // Set the desired width - height: 40.sp, // Set the desired height - child: FloatingActionButton( - focusNode: changeReadingModeFocusNode, - focusColor: Theme.of(context).primaryColor, - backgroundColor: Colors.black.withOpacity(.5), - child: Icon( - Icons.menu_book, - color: Colors.white, - size: 15.sp, + floatingActionButton: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 30.sp, // Set the desired width + height: 30.sp, // Set the desired height + child: Consumer( + builder: (context, ref, child) { + return ref.watch(reciteNotifierProvider).when( + data: (reciter) { + return FloatingActionButton( + backgroundColor: Colors.black.withOpacity(.5), + child: Icon( + Icons.schedule, + color: Colors.white, + size: 15.sp, + ), + onPressed: () async { + showDialog( + context: context, + builder: (BuildContext context) { + return ScheduleScreen(reciterList: reciter.reciters); + }, + ); + }, + ); + }, + loading: () => CircularProgressIndicator(), + error: (error, stackTrace) => Icon(Icons.error, color: Colors.red), + ); + }, + ), ), - onPressed: () async { - ref.read(quranNotifierProvider.notifier).selectModel(QuranMode.reading); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => QuranReadingScreen(), + SizedBox( + width: 5.sp, + ), + SizedBox( + width: 30.sp, // Set the desired width + height: 30.sp, // Set the desired height + child: FloatingActionButton( + focusNode: changeReadingModeFocusNode, + focusColor: Theme.of(context).primaryColor, + backgroundColor: Colors.black.withOpacity(.5), + child: Icon( + Icons.menu_book, + color: Colors.white, + size: 15.sp, ), - ); - }, - ), + onPressed: () async { + ref.read(quranNotifierProvider.notifier).selectModel(QuranMode.reading); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => QuranReadingScreen(), + ), + ); + }, + ), + ), + SizedBox( + width: 5.sp, + ), + audioState.when( + data: (state) { + if (!state.shouldShowControls) { + return const SizedBox.shrink(); // Hide controls when scheduling is disabled + } + + return SizedBox( + width: 30.sp, // Set the desired width + height: 30.sp, // Set the desired height + child: FloatingActionButton( + focusNode: scheduleListeningFocusNode, + focusColor: Theme.of(context).primaryColor, + backgroundColor: state.status == AudioStatus.playing ? Colors.red : Colors.black.withOpacity(.5), + child: Icon( + color: Colors.white, + state.status == AudioStatus.playing ? Icons.pause : Icons.play_arrow, + ), + onPressed: () { + ref.read(audioControlProvider.notifier).togglePlayback(); + }, + ), + ); + }, + loading: () => const CircularProgressIndicator(), + error: (error, stack) => Text('Error: $error'), + ) + ], ), appBar: AppBar( backgroundColor: Color(0xFF28262F), diff --git a/lib/src/pages/quran/page/schedule_screen.dart b/lib/src/pages/quran/page/schedule_screen.dart new file mode 100644 index 00000000..b80f3e6a --- /dev/null +++ b/lib/src/pages/quran/page/schedule_screen.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mawaqit/i18n/l10n.dart'; +import 'package:mawaqit/src/pages/quran/widget/schedule_screen_widgets/custom_scheduling_drop_down.dart'; +import 'package:mawaqit/src/state_management/quran/schedule_listening/schedule_listening_notifier.dart'; +import '../../../domain/model/quran/moshaf_model.dart'; +import '../../../domain/model/quran/reciter_model.dart'; +import '../widget/schedule_screen_widgets/androidtv_timepicker.dart'; +import '../widget/schedule_screen_widgets/focusable_timepicker.dart'; +import '../widget/schedule_screen_widgets/custom_overlay_notification.dart'; +import '../../../state_management/quran/quran/quran_notifier.dart'; + +class ScheduleScreen extends ConsumerStatefulWidget { + final List reciterList; + + const ScheduleScreen({ + super.key, + required this.reciterList, + }); + + @override + ConsumerState createState() => _ScheduleScreenState(); +} + +class _ScheduleScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + _initializeData(); + } + + void _initializeData() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(quranNotifierProvider.notifier).getSuwarByLanguage(); + ref.read(scheduleProvider.notifier).updateReciterList(widget.reciterList); + }); + } + + Future _handleSaveSchedule() async { + final success = await ref.read(scheduleProvider.notifier).saveSchedule(); + if (success) { + _showNotification(S.of(context).scheduleSaved); + if (mounted) Navigator.of(context).pop(); + } else { + _showNotification(S.of(context).completeAllFields); + } + } + + void _showNotification(String message) { + late OverlayEntry overlayEntry; + + overlayEntry = OverlayEntry( + builder: (context) => CustomOverlayNotification( + message: message, + onDismiss: () => overlayEntry.remove(), + ), + ); + + Overlay.of(context)?.insert(overlayEntry); + + Future.delayed(const Duration(seconds: 3)).then((_) { + if (overlayEntry.mounted) { + overlayEntry.remove(); + } + }); + } + + Future _handleTimeSelection( + BuildContext context, bool isStartTime) async { + final scheduleNotifier = ref.read(scheduleProvider.notifier); + final currentState = ref.read(scheduleProvider).value!; + + await showDialog( + context: context, + builder: (BuildContext context) => TVFriendlyTimePicker( + initialTime: + isStartTime ? currentState.startTime : currentState.endTime, + onTimeSelected: (TimeOfDay selectedTime) async { + if (isStartTime) { + await _handleStartTimeSelection( + selectedTime, currentState, scheduleNotifier); + } else { + await _handleEndTimeSelection(selectedTime, scheduleNotifier); + } + }, + ), + ); + } + + Future _handleStartTimeSelection( + TimeOfDay selectedTime, + dynamic currentState, + dynamic scheduleNotifier, + ) async { + await scheduleNotifier.setStartTime(selectedTime); + final endTime = currentState.endTime; + + if (_shouldAdjustEndTime(selectedTime, endTime)) { + await scheduleNotifier.setEndTime( + TimeOfDay( + hour: (selectedTime.hour + 1) % 24, + minute: selectedTime.minute, + ), + ); + } + } + + bool _shouldAdjustEndTime(TimeOfDay selectedTime, TimeOfDay endTime) { + return endTime.hour < selectedTime.hour || + (endTime.hour == selectedTime.hour && + endTime.minute <= selectedTime.minute); + } + + Future _handleEndTimeSelection( + TimeOfDay selectedTime, + dynamic scheduleNotifier, + ) async { + final success = await scheduleNotifier.setEndTime(selectedTime); + if (!success) { + _showNotification(S.of(context).endTimeAfter); + } + } + + @override + Widget build(BuildContext context) { + final AsyncValue scheduleState = ref.watch(scheduleProvider); + final AsyncValue quranState = ref.watch(quranNotifierProvider); + + return Dialog( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 400, + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: scheduleState.when( + data: (state) => + _buildScheduleContent(state, quranState, context), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Text('Error: $error'), + ), + ), + ), + ), + ); + } + + Widget _buildScheduleContent( + dynamic state, + AsyncValue quranState, + BuildContext context, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + const SizedBox(height: 16), + _buildScheduleSwitch(state), + const SizedBox(height: 8), + _buildScheduleDescription(), + const SizedBox(height: 16), + if (state.isScheduleEnabled) ...[ + ..._buildScheduleOptions(state, quranState), + const SizedBox(height: 24), + ], + _buildActionButtons(context), + const SizedBox(height: 10), + ], + ); + } + + Widget _buildHeader(BuildContext context) { + return Text( + S.of(context).scheduleListening, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ); + } + + Widget _buildScheduleSwitch(dynamic state) { + return SwitchListTile( + title: Text(S.of(context).enableScheduling), + value: state.isScheduleEnabled, + onChanged: (bool value) { + ref.read(scheduleProvider.notifier).setScheduleEnabled(value); + }, + ); + } + + Widget _buildScheduleDescription() { + return Text( + S.of(context).scheduleDesc, + style: TextStyle(fontSize: 12, color: Colors.grey), + ); + } + + List _buildScheduleOptions( + dynamic state, AsyncValue quranState) { + return [ + _buildTimePicker(S.of(context).startTime, state.startTime, true), + const SizedBox(height: 16), + _buildTimePicker(S.of(context).endTime, state.endTime, false), + const SizedBox(height: 24), + _buildReciterDropdown(state), + const SizedBox(height: 16), + if (state.selectedReciter != null) ...[ + _buildMoshafDropdown(state), + const SizedBox(height: 16), + ], + _buildRandomSurahCheckbox(state, context), + const SizedBox(height: 16), + if (state.selectedMoshaf != null && !state.isRandomEnabled) + _buildSurahDropdown(state, quranState), + ]; + } + + Widget _buildTimePicker(String label, TimeOfDay time, bool isStartTime) { + return FocusableTimePicker( + label: label, + time: time, + isStartTime: isStartTime, + onTap: _handleTimeSelection, + ); + } + + Widget _buildReciterDropdown(dynamic state) { + return CustomDropdown( + value: state.selectedReciter, + items: widget.reciterList, + onChanged: (ReciterModel? newValue) { + ref.read(scheduleProvider.notifier).setSelectedReciter(newValue); + }, + hint: S.of(context).selectReciter, + getLabel: (reciter) => reciter.name, + ); + } + + Widget _buildMoshafDropdown(dynamic state) { + return CustomDropdown( + value: state.selectedMoshaf, + items: state.selectedReciter!.moshaf, + onChanged: (MoshafModel? newValue) { + ref.read(scheduleProvider.notifier).setSelectedMoshaf(newValue); + }, + hint: S.of(context).selectMoshaf, + getLabel: (moshaf) => moshaf.name, + ); + } + + Widget _buildRandomSurahCheckbox(dynamic state, BuildContext context) { + return CheckboxListTile( + activeColor: Theme.of(context).primaryColor, + title: Text(S.of(context).randomSurahSelection), + value: state.isRandomEnabled, + onChanged: (bool? value) { + ref.read(scheduleProvider.notifier).setRandomEnabled(value ?? false); + }, + ); + } + + Widget _buildSurahDropdown(dynamic state, AsyncValue quranState) { + return quranState.when( + data: (data) => CustomDropdown( + value: state.selectedSurahId, + items: state.selectedMoshaf!.surahList, + onChanged: (int? newValue) { + ref.read(scheduleProvider.notifier).setSelectedSurahId(newValue); + }, + hint: S.of(context).selectSurah, + getLabel: (surahId) { + final surah = data.suwar.firstWhere((s) => s.id == surahId); + return '${surah.id}. ${surah.name}'; + }, + ), + loading: () => const CircularProgressIndicator(), + error: (error, stack) => Text('Error: $error'), + ); + } + + Widget _buildActionButtons(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(S.of(context).cancel), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.focused)) { + return Theme.of(context).primaryColor; + } + return null; + }, + ), + ), + onPressed: _handleSaveSchedule, + child: Text(S.of(context).save), + ), + ], + ); + } +} diff --git a/lib/src/pages/quran/widget/schedule_screen_widgets/androidtv_timepicker.dart b/lib/src/pages/quran/widget/schedule_screen_widgets/androidtv_timepicker.dart new file mode 100644 index 00000000..fd086607 --- /dev/null +++ b/lib/src/pages/quran/widget/schedule_screen_widgets/androidtv_timepicker.dart @@ -0,0 +1,91 @@ +// The TVFriendlyTimePicker class remains unchanged +import 'package:flutter/material.dart'; +import 'package:mawaqit/i18n/l10n.dart'; + +class TVFriendlyTimePicker extends StatefulWidget { + final TimeOfDay initialTime; + final Function(TimeOfDay) onTimeSelected; + + TVFriendlyTimePicker( + {required this.initialTime, required this.onTimeSelected}); + + @override + _TVFriendlyTimePickerState createState() => _TVFriendlyTimePickerState(); +} + +class _TVFriendlyTimePickerState extends State { + late int _hour; + late int _minute; + + @override + void initState() { + super.initState(); + _hour = widget.initialTime.hour; + _minute = widget.initialTime.minute; + } + + void _updateTime() { + widget.onTimeSelected(TimeOfDay(hour: _hour, minute: _minute)); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(S.of(context).selectTime), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildNumberPicker( + _hour, 0, 23, (value) => setState(() => _hour = value)), + Text(':'), + _buildNumberPicker( + _minute, 0, 59, (value) => setState(() => _minute = value)), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(S.of(context).cancel), + ), + ElevatedButton( + onPressed: _updateTime, + child: Text(S.of(context).ok), + ), + ], + ); + } + + Widget _buildNumberPicker( + int value, int minValue, int maxValue, Function(int) onChanged) { + return Column( + children: [ + IconButton( + icon: Icon(Icons.arrow_drop_up), + onPressed: () { + int newValue = value + 1; + if (newValue > maxValue) newValue = minValue; + onChanged(newValue); + }, + ), + Text( + value.toString().padLeft(2, '0'), + style: TextStyle(fontSize: 24), + ), + IconButton( + icon: Icon(Icons.arrow_drop_down), + onPressed: () { + int newValue = value - 1; + if (newValue < minValue) newValue = maxValue; + onChanged(newValue); + }, + ), + ], + ); + } +} diff --git a/lib/src/pages/quran/widget/schedule_screen_widgets/custom_overlay_notification.dart b/lib/src/pages/quran/widget/schedule_screen_widgets/custom_overlay_notification.dart new file mode 100644 index 00000000..936fc1f1 --- /dev/null +++ b/lib/src/pages/quran/widget/schedule_screen_widgets/custom_overlay_notification.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class CustomOverlayNotification extends StatelessWidget { + final String message; + final VoidCallback onDismiss; + + const CustomOverlayNotification({ + Key? key, + required this.message, + required this.onDismiss, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Positioned( + top: MediaQuery.of(context).padding.top + 10, + left: 10, + right: 10, + child: Material( + color: Colors.transparent, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle(color: Colors.white), + ), + ), + IconButton( + icon: Icon(Icons.close, color: Colors.white), + onPressed: onDismiss, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/pages/quran/widget/schedule_screen_widgets/custom_scheduling_drop_down.dart b/lib/src/pages/quran/widget/schedule_screen_widgets/custom_scheduling_drop_down.dart new file mode 100644 index 00000000..7cce1c23 --- /dev/null +++ b/lib/src/pages/quran/widget/schedule_screen_widgets/custom_scheduling_drop_down.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class CustomDropdown extends StatelessWidget { + final T? value; + final List items; + final void Function(T?) onChanged; + final String hint; + final String Function(T) getLabel; + + const CustomDropdown({ + super.key, + required this.value, + required this.items, + required this.onChanged, + required this.hint, + required this.getLabel, + }); + + @override + Widget build(BuildContext context) { + final bool valueExists = items.any((item) => item == value); + + return Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: valueExists ? value : null, + hint: Text(hint), + isExpanded: true, + onChanged: onChanged, + items: items.map>((T item) { + return DropdownMenuItem( + value: item, + child: Text(getLabel(item)), + ); + }).toList(), + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/pages/quran/widget/schedule_screen_widgets/focusable_timepicker.dart b/lib/src/pages/quran/widget/schedule_screen_widgets/focusable_timepicker.dart new file mode 100644 index 00000000..a10f1363 --- /dev/null +++ b/lib/src/pages/quran/widget/schedule_screen_widgets/focusable_timepicker.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +class FocusableTimePicker extends StatefulWidget { + final String label; + final TimeOfDay time; + final bool isStartTime; + final Function(BuildContext, bool) onTap; + + FocusableTimePicker({ + required this.label, + required this.time, + required this.isStartTime, + required this.onTap, + }); + + @override + _FocusableTimePickerState createState() => _FocusableTimePickerState(); +} + +class _FocusableTimePickerState extends State { + bool _isFocused = false; + + @override + Widget build(BuildContext context) { + return FocusableActionDetector( + onFocusChange: (hasFocus) { + setState(() { + _isFocused = hasFocus; + }); + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (ActivateIntent intent) { + widget.onTap(context, widget.isStartTime); + return null; + }, + ), + }, + child: InkWell( + onTap: () => widget.onTap(context, widget.isStartTime), + child: Container( + decoration: BoxDecoration( + color: _isFocused ? Theme.of(context).primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + title: Text( + widget.label, + style: TextStyle(color: _isFocused ? Colors.white : null), + ), + trailing: Text( + widget.time.format(context), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _isFocused ? Colors.white : null, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/services/background_audio_schedule_service.dart b/lib/src/services/background_audio_schedule_service.dart new file mode 100644 index 00000000..c4b76961 --- /dev/null +++ b/lib/src/services/background_audio_schedule_service.dart @@ -0,0 +1,318 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:mawaqit/src/const/constants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter/material.dart'; +import 'package:audio_session/audio_session.dart'; + +/// BackgroundAudioService class to handle all audio-related operations +class BackgroundAudioScheduleService { + static AudioPlayer? _audioPlayer; + + static bool isPlaying() => _audioPlayer?.playing ?? false; + static AudioPlayer? get player => _audioPlayer; + + /// Initialize the background service + static Future initialize() async { + final service = FlutterBackgroundService(); + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: onStart, + autoStart: true, + isForegroundMode: true, + ), + iosConfiguration: IosConfiguration( + autoStart: true, + onForeground: onStart, + ), + ); + await service.startService(); + } + + /// Configure and start audio playback + static Future playAudio(dynamic surahSource, + {bool createPlaylist = false}) async { + try { + _audioPlayer ??= AudioPlayer(); + + await _configureAudioSession(); + await _setupAudioSource(surahSource, createPlaylist); + await _startPlayback(createPlaylist); + } catch (e) { + print('Error playing audio: $e'); + } + } + + /// Stop audio playback + static Future stopPlayback() async { + print('Pausing surah playback'); + await _audioPlayer?.pause(); + } + + /// Configure audio session settings + static Future _configureAudioSession() async { + final session = await AudioSession.instance; + await session.configure(_getAudioSessionConfiguration()); + await session.setActive(true); + await _audioPlayer?.setVolume(1.0); + } + + /// Get audio session configuration + static AudioSessionConfiguration _getAudioSessionConfiguration() { + return AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playback, + avAudioSessionCategoryOptions: + AVAudioSessionCategoryOptions.defaultToSpeaker, + avAudioSessionMode: AVAudioSessionMode.defaultMode, + avAudioSessionRouteSharingPolicy: + AVAudioSessionRouteSharingPolicy.defaultPolicy, + avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, + androidAudioAttributes: const AndroidAudioAttributes( + contentType: AndroidAudioContentType.music, + flags: AndroidAudioFlags.none, + usage: AndroidAudioUsage.media, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + androidWillPauseWhenDucked: true, + ); + } + + /// Setup audio source based on playlist or single audio + static Future _setupAudioSource( + dynamic surahSource, bool createPlaylist) async { + if (createPlaylist) { + await _setupPlaylist(surahSource); + } else { + await _setupSingleAudio(surahSource); + } + } + + /// Setup playlist audio source + static Future _setupPlaylist(dynamic surahSource) async { + final playlist = ConcatenatingAudioSource( + children: (surahSource as List).map((source) { + if (source is String) { + return AudioSource.uri(Uri.parse(source)); + } else if (source is AudioSource) { + return source; + } + throw ArgumentError('Invalid source type: ${source.runtimeType}'); + }).toList(), + ); + await _audioPlayer?.setAudioSource(playlist); + await _audioPlayer?.setLoopMode(LoopMode.all); + } + + /// Setup single audio source + static Future _setupSingleAudio(dynamic surahSource) async { + final source = surahSource is String + ? AudioSource.uri(Uri.parse(surahSource)) + : surahSource as AudioSource; + await _audioPlayer?.setAudioSource(source); + await _audioPlayer?.setLoopMode(LoopMode.one); + } + + /// Start playback and configure completion handling + static Future _startPlayback(bool createPlaylist) async { + await _audioPlayer?.play(); + _audioPlayer?.playbackEventStream.listen((event) { + if (event.processingState == ProcessingState.completed && + !createPlaylist) { + _audioPlayer?.seek(Duration.zero); + _audioPlayer?.play(); + } + }); + } +} + +/// Schedule management class +class ScheduleManager { + /// Check and manage schedule + static Future checkSchedule() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + if (await _shouldSkipSchedule(prefs)) return; + + final scheduleData = await _getScheduleData(prefs); + if (scheduleData == null) return; + + final currentTime = TimeOfDay.now(); + await _handleScheduleExecution(currentTime, scheduleData, prefs); + } + + /// Check if schedule should be skipped + static Future _shouldSkipSchedule(SharedPreferences prefs) async { + final isPendingSchedule = prefs + .getBool(BackgroundScheduleAudioServiceConstant.kPendingSchedule) ?? + false; + final isManuallyPaused = + prefs.getBool(BackgroundScheduleAudioServiceConstant.kManualPause) ?? + false; + final isScheduleEnabled = prefs + .getBool(BackgroundScheduleAudioServiceConstant.kScheduleEnabled) ?? + false; + + if (!isScheduleEnabled || isManuallyPaused || isPendingSchedule) { + if (BackgroundAudioScheduleService.isPlaying()) { + await BackgroundAudioScheduleService.stopPlayback(); + FlutterBackgroundService() + .invoke('kAudioStateChanged', {'isPlaying': false}); + } + return true; + } + return false; + } + + /// Get schedule data from preferences + static Future _getScheduleData(SharedPreferences prefs) async { + final startTimeString = + prefs.getString(BackgroundScheduleAudioServiceConstant.kStartTime); + final endTimeString = + prefs.getString(BackgroundScheduleAudioServiceConstant.kEndTime); + + if (startTimeString == null || endTimeString == null) return null; + + return ScheduleData( + startTime: _parseTimeOfDay(startTimeString), + endTime: _parseTimeOfDay(endTimeString), + isRandomEnabled: prefs + .getBool(BackgroundScheduleAudioServiceConstant.kRandomEnabled) ?? + false, + randomUrls: prefs + .getStringList(BackgroundScheduleAudioServiceConstant.kRandomUrls), + selectedSurah: + prefs.getInt(BackgroundScheduleAudioServiceConstant.kSelectedSurah) ?? + 0, + selectedSurahUrl: prefs + .getString(BackgroundScheduleAudioServiceConstant.kSelectedSurahUrl), + ); + } + + /// Handle schedule execution + static Future _handleScheduleExecution(TimeOfDay currentTime, + ScheduleData scheduleData, SharedPreferences prefs) async { + if (_isTimeInRange( + currentTime, scheduleData.startTime, scheduleData.endTime)) { + if (!BackgroundAudioScheduleService.isPlaying()) { + await _startScheduledPlayback(scheduleData); + FlutterBackgroundService() + .invoke('kAudioStateChanged', {'isPlaying': true}); + } + } else if (BackgroundAudioScheduleService.isPlaying()) { + await BackgroundAudioScheduleService.stopPlayback(); + FlutterBackgroundService() + .invoke('kAudioStateChanged', {'isPlaying': false}); + } + } + + /// Start scheduled playback + static Future _startScheduledPlayback(ScheduleData scheduleData) async { + if (scheduleData.isRandomEnabled && scheduleData.randomUrls != null) { + await BackgroundAudioScheduleService.playAudio(scheduleData.randomUrls, + createPlaylist: true); + } else if (scheduleData.selectedSurahUrl != null) { + final surahIdStr = scheduleData.selectedSurah.toString().padLeft(3, '0'); + final surahUrl = "${scheduleData.selectedSurahUrl}$surahIdStr.mp3"; + await BackgroundAudioScheduleService.playAudio(surahUrl); + } + } + + /// Parse time string to TimeOfDay + static TimeOfDay _parseTimeOfDay(String timeString) { + final parts = timeString.split(':'); + return TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1])); + } + + /// Check if current time is within range + static bool _isTimeInRange( + TimeOfDay current, TimeOfDay start, TimeOfDay end) { + final now = current.hour * 60 + current.minute; + final startMinutes = start.hour * 60 + start.minute; + final endMinutes = end.hour * 60 + end.minute; + + if (startMinutes <= endMinutes) { + return now >= startMinutes && now < endMinutes; + } + return now >= startMinutes || now < endMinutes; + } +} + +/// Data class for schedule information +class ScheduleData { + final TimeOfDay startTime; + final TimeOfDay endTime; + final bool isRandomEnabled; + final List? randomUrls; + final int selectedSurah; + final String? selectedSurahUrl; + + ScheduleData({ + required this.startTime, + required this.endTime, + required this.isRandomEnabled, + this.randomUrls, + required this.selectedSurah, + this.selectedSurahUrl, + }); +} + +/// Service entry points +@pragma('vm:entry-point') +void onStart(ServiceInstance service) async { + DartPluginRegistrant.ensureInitialized(); + print("Background service started"); + + _setupPeriodicScheduleCheck(service); + _setupServiceListeners(service); +} + +/// Setup periodic schedule check +void _setupPeriodicScheduleCheck(ServiceInstance service) { + Timer.periodic(Duration(minutes: 1), (timer) async { + print("Checking schedule: ${DateTime.now()}"); + await ScheduleManager.checkSchedule(); + }); +} + +/// Setup service listeners +void _setupServiceListeners(ServiceInstance service) { + service.on('update_schedule').listen((event) async { + print("Schedule updated, reloading preferences"); + await ScheduleManager.checkSchedule(); + }); + + service.on('kStopAudio').listen((event) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + BackgroundScheduleAudioServiceConstant.kManualPause, true); + await BackgroundAudioScheduleService.stopPlayback(); + service.invoke('kAudioStateChanged', {'isPlaying': false}); + }); + + service.on('stopService').listen((event) { + service.stopSelf(); + }); + + service.on('kGetPlaybackState').listen((event) async { + service.invoke('kAudioStateChanged', + {'isPlaying': BackgroundAudioScheduleService.isPlaying()}); + }); + + service.on('kResumeAudio').listen((event) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + BackgroundScheduleAudioServiceConstant.kManualPause, false); + + if (BackgroundAudioScheduleService.isPlaying()) { + await BackgroundAudioScheduleService.player?.play(); + service.invoke('kAudioStateChanged', {'isPlaying': true}); + } else { + await ScheduleManager.checkSchedule(); + } + }); +} diff --git a/lib/src/state_management/quran/schedule_listening/audio_control_notifier.dart b/lib/src/state_management/quran/schedule_listening/audio_control_notifier.dart new file mode 100644 index 00000000..0c883369 --- /dev/null +++ b/lib/src/state_management/quran/schedule_listening/audio_control_notifier.dart @@ -0,0 +1,179 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:mawaqit/src/state_management/quran/schedule_listening/audio_control_state.dart'; +import 'package:mawaqit/src/state_management/quran/schedule_listening/schedule_listening_notifier.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../const/constants.dart'; + +/// Provider for the AudioControlNotifier +final audioControlProvider = + AsyncNotifierProvider( + () => AudioControlNotifier(), +); + +/// Manages the audio control state and interactions with the background service +class AudioControlNotifier extends AsyncNotifier { + /// Background service instance + final FlutterBackgroundService _service; + + /// Creates an AudioControlNotifier with an optional background service + AudioControlNotifier({FlutterBackgroundService? service}) + : _service = service ?? FlutterBackgroundService(); + + @override + Future build() async { + await _initializeListeners(); + return _getInitialState(); + } + + /// Initializes all event listeners for the audio control + Future _initializeListeners() async { + _setupAudioStateListener(); + _setupScheduleStateListener(); + await _checkPlaybackState(); + } + + /// Sets up the listener for audio state changes from the background service + void _setupAudioStateListener() { + _service + .on(BackgroundScheduleAudioServiceConstant.kAudioStateChanged) + .listen(_handleAudioStateChange); + } + + /// Handles audio state changes received from the background service + void _handleAudioStateChange(Map? event) { + if (event == null || event['isPlaying'] == null) return; + + final isPlaying = event['isPlaying'] as bool; + _updatePlaybackState(isPlaying); + } + + /// Updates the playback state in the notifier + void _updatePlaybackState(bool isPlaying) { + try { + state = AsyncData(state.value!.copyWith( + status: isPlaying ? AudioStatus.playing : AudioStatus.paused, + )); + } catch (e) { + _handleError('Failed to update playback state: $e'); + } + } + + /// Sets up the listener for schedule state changes + void _setupScheduleStateListener() { + ref.listen(scheduleProvider, _handleScheduleStateChange); + } + + /// Handles schedule state changes + void _handleScheduleStateChange( + AsyncValue? previous, AsyncValue next) { + if (next.value != null) { + try { + state = AsyncData(state.value!.copyWith( + shouldShowControls: next.value!.isScheduleEnabled, + )); + } catch (e) { + _handleError('Failed to update controls visibility: $e'); + } + } + } + + /// Retrieves the initial state from SharedPreferences + Future _getInitialState() async { + try { + final prefs = await SharedPreferences.getInstance(); + final isScheduleEnabled = prefs.getBool( + BackgroundScheduleAudioServiceConstant.kScheduleEnabled) ?? + false; + + return AudioControlState( + status: AudioStatus.paused, + shouldShowControls: isScheduleEnabled, + ); + } catch (e) { + _handleError('Failed to get initial state: $e'); + return const AudioControlState( + status: AudioStatus.paused, + shouldShowControls: false, + ); + } + } + + /// Checks the current playback state with the background service + Future _checkPlaybackState() async { + try { + await _updateLoadingState(true); + _service.invoke(BackgroundScheduleAudioServiceConstant.kGetPlaybackState); + } catch (e) { + _handleError('Failed to check playback state: $e'); + } finally { + await _updateLoadingState(false); + } + } + + /// Updates the loading state of the notifier + Future _updateLoadingState(bool isLoading) async { + try { + state = AsyncData(state.value!.copyWith(isLoading: isLoading)); + } catch (e) { + _handleError('Failed to update loading state: $e'); + } + } + + /// Handles errors by updating the state with the error message + void _handleError(String errorMessage) { + try { + state = AsyncData(state.value!.copyWith( + isLoading: false, + error: errorMessage, + )); + } catch (e) { + // If we can't even update the error state, log it + print('Critical error: Unable to update error state: $e'); + } + } + + /// Toggles the playback state between playing and paused + Future togglePlayback() async { + if (state.value == null) return; + + final currentStatus = state.value!.status; + final newStatus = currentStatus == AudioStatus.playing + ? AudioStatus.paused + : AudioStatus.playing; + + try { + // Update state optimistically + await _updatePlaybackStateWithLoading(newStatus); + + // Invoke appropriate service method + _service.invoke(newStatus == AudioStatus.paused + ? BackgroundScheduleAudioServiceConstant.kStopAudio + : BackgroundScheduleAudioServiceConstant.kResumeAudio); + } catch (e) { + // Revert to previous state on error + await _updatePlaybackStateWithLoading(currentStatus); + _handleError('Failed to toggle playback: $e'); + } + } + + /// Updates the playback state with loading indicator + Future _updatePlaybackStateWithLoading(AudioStatus status) async { + try { + state = AsyncData(state.value!.copyWith( + status: status, + isLoading: true, + )); + + // Simulate a small delay for UI feedback + await Future.delayed(const Duration(milliseconds: 100)); + + state = AsyncData(state.value!.copyWith( + isLoading: false, + )); + } catch (e) { + _handleError('Failed to update playback state: $e'); + } + } +} diff --git a/lib/src/state_management/quran/schedule_listening/audio_control_state.dart b/lib/src/state_management/quran/schedule_listening/audio_control_state.dart new file mode 100644 index 00000000..b1a554ef --- /dev/null +++ b/lib/src/state_management/quran/schedule_listening/audio_control_state.dart @@ -0,0 +1,35 @@ +// audio_status.dart +import 'package:equatable/equatable.dart'; + +enum AudioStatus { playing, paused } + +class AudioControlState extends Equatable { + final AudioStatus status; + final bool isLoading; + final String? error; + final bool shouldShowControls; + + const AudioControlState({ + this.status = AudioStatus.paused, + this.isLoading = false, + this.error, + this.shouldShowControls = false, + }); + + AudioControlState copyWith({ + AudioStatus? status, + bool? isLoading, + String? error, + bool? shouldShowControls, + }) { + return AudioControlState( + status: status ?? this.status, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + shouldShowControls: shouldShowControls ?? this.shouldShowControls, + ); + } + + @override + List get props => [status, isLoading, error, shouldShowControls]; +} diff --git a/lib/src/state_management/quran/schedule_listening/schedule_listening_notifier.dart b/lib/src/state_management/quran/schedule_listening/schedule_listening_notifier.dart new file mode 100644 index 00000000..5e8771ed --- /dev/null +++ b/lib/src/state_management/quran/schedule_listening/schedule_listening_notifier.dart @@ -0,0 +1,325 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mawaqit/src/services/background_audio_schedule_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import '../../../const/constants.dart'; +import '../../../domain/model/quran/moshaf_model.dart'; +import '../../../domain/model/quran/reciter_model.dart'; +import 'audio_control_notifier.dart'; +import 'schedule_listening_state.dart'; + +/// A notifier that manages the scheduling state for Quran recitation scheduling. +/// +/// This class handles the persistence and management of schedule-related settings, +/// including time ranges, reciter selection, and playback preferences. +class ScheduleNotifier extends AsyncNotifier { + late final SharedPreferences _prefs; + final FlutterBackgroundService _service = FlutterBackgroundService(); + + @override + Future build() async { + _prefs = await SharedPreferences.getInstance(); + await BackgroundAudioScheduleService.initialize(); + return _loadSavedSchedule(); + } + + /// Loads the saved schedule state from SharedPreferences. + Future _loadSavedSchedule() async { + final isScheduleEnabled = _prefs + .getBool(BackgroundScheduleAudioServiceConstant.kScheduleEnabled) ?? + false; + final startTime = _parseTimeOfDay( + _prefs.getString(BackgroundScheduleAudioServiceConstant.kStartTime) ?? + '08:00'); + final endTime = _parseTimeOfDay( + _prefs.getString(BackgroundScheduleAudioServiceConstant.kEndTime) ?? + '20:00'); + final isRandomEnabled = + _prefs.getBool(BackgroundScheduleAudioServiceConstant.kRandomEnabled) ?? + false; + + final savedReciterName = _prefs + .getString(BackgroundScheduleAudioServiceConstant.kSelectedReciter); + final reciterList = state.value?.reciterList ?? []; + + final selectedReciter = _findSelectedReciter(savedReciterName, reciterList); + final selectedMoshaf = _findSelectedMoshaf(selectedReciter); + + return ScheduleState( + isScheduleEnabled: isScheduleEnabled, + startTime: startTime, + endTime: endTime, + selectedReciter: selectedReciter, + selectedMoshaf: selectedMoshaf, + selectedSurahId: + _prefs.getInt(BackgroundScheduleAudioServiceConstant.kSelectedSurah), + isRandomEnabled: isRandomEnabled, + reciterList: reciterList, + ); + } + + /// Finds the selected reciter from the saved name and reciter list. + ReciterModel? _findSelectedReciter( + String? savedReciterName, List reciterList) { + if (savedReciterName == null || reciterList.isEmpty) return null; + + return reciterList.firstWhere( + (reciter) => reciter.name == savedReciterName, + orElse: () => reciterList.first, + ); + } + + /// Finds the selected moshaf for the given reciter. + MoshafModel? _findSelectedMoshaf(ReciterModel? selectedReciter) { + if (selectedReciter == null || selectedReciter.moshaf.isEmpty) return null; + + final savedMoshafId = _prefs + .getString(BackgroundScheduleAudioServiceConstant.kSelectedMoshaf); + if (savedMoshafId == null) return selectedReciter.moshaf.first; + + return selectedReciter.moshaf.firstWhere( + (moshaf) => moshaf.id == savedMoshafId, + orElse: () => selectedReciter.moshaf.first, + ); + } + + /// Parses a time string in format "HH:mm" to TimeOfDay. + TimeOfDay _parseTimeOfDay(String timeString) { + final parts = timeString.split(':'); + return TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1])); + } + + /// Enables or disables the schedule. + Future setScheduleEnabled(bool enabled) async { + if (enabled) { + await _prefs.setBool( + BackgroundScheduleAudioServiceConstant.kPendingSchedule, true); + } + + state = AsyncData(state.value!.copyWith(isScheduleEnabled: enabled)); + + if (!enabled) { + await _disableSchedule(); + state = AsyncData(state.value!.copyWith(isScheduleEnabled: false)); + } + } + + Future setStartTime(TimeOfDay time) async { + state = AsyncData(state.value!.copyWith(startTime: time)); + } + + /// Sets the end time for the schedule. Returns false if the end time is before start time. + Future setEndTime(TimeOfDay time) async { + final isValidEndTime = _isValidEndTime(time); + if (isValidEndTime) { + state = AsyncData(state.value!.copyWith(endTime: time)); + } + return isValidEndTime; + } + + bool _isValidEndTime(TimeOfDay time) { + final startTime = state.value!.startTime; + return time.hour > startTime.hour || + (time.hour == startTime.hour && time.minute > startTime.minute); + } + + Future setSelectedReciter(ReciterModel? reciter) async { + state = AsyncData(state.value!.copyWith( + selectedReciter: reciter, + selectedMoshaf: + reciter?.moshaf.isNotEmpty == true ? reciter!.moshaf.first : null, + selectedSurahId: null, + isRandomEnabled: false, + )); + } + + Future setSelectedMoshaf(MoshafModel? moshaf) async { + if (moshaf == null) { + state = AsyncData(state.value!.copyWith( + selectedMoshaf: null, + selectedSurahId: null, + )); + return; + } + + final currentReciter = state.value!.selectedReciter; + if (currentReciter != null) { + final exactMoshaf = _findExactMoshaf(currentReciter, moshaf); + state = AsyncData(state.value!.copyWith( + selectedReciter: currentReciter, + selectedMoshaf: exactMoshaf, + selectedSurahId: exactMoshaf.surahList.isNotEmpty + ? exactMoshaf.surahList.first + : null, + )); + } + } + + MoshafModel _findExactMoshaf(ReciterModel reciter, MoshafModel moshaf) { + return reciter.moshaf.firstWhere( + (m) => m.id == moshaf.id, + orElse: () => moshaf, + ); + } + + void setRandomEnabled(bool enabled) { + state = AsyncData(state.value!.copyWith( + isRandomEnabled: enabled, + selectedSurahId: enabled ? null : state.value!.selectedSurahId, + )); + } + + void setSelectedSurahId(int? surahId) { + state = AsyncData(state.value!.copyWith( + selectedSurahId: surahId, + isRandomEnabled: surahId == null ? state.value!.isRandomEnabled : false, + )); + } + + /// Generates a list of random Surah URLs for playback. + List _generateRandomUrls() { + final random = Random(); + final currentState = state.value!; + final availableSurahs = currentState.selectedMoshaf!.surahList; + final count = min(5, availableSurahs.length); + + return List.generate(count, (_) { + final randomSurahId = + availableSurahs[random.nextInt(availableSurahs.length)] + .toString() + .padLeft(3, '0'); + return '${currentState.selectedMoshaf!.server}$randomSurahId.mp3'; + }); + } + + /// Saves the current schedule configuration. + Future saveSchedule() async { + final currentState = state.value!; + + if (!_isValidScheduleState(currentState)) { + return false; + } + + await _disableSchedule(); + await _prefs + .remove(BackgroundScheduleAudioServiceConstant.kPendingSchedule); + await _savePreferences(currentState); + + final now = TimeOfDay.now(); + final isInRange = _isTimeInRange( + now, + currentState.startTime, + currentState.endTime, + ); + + await _prefs.reload(); + _service.invoke('update_schedule'); + + if (isInRange) { + ref.read(audioControlProvider.notifier).togglePlayback(); + await _prefs.setBool( + BackgroundScheduleAudioServiceConstant.kManualPause, false); + } + + return true; + } + + bool _isValidScheduleState(ScheduleState state) { + return state.isScheduleEnabled == true && + state.selectedReciter != null && + state.selectedMoshaf != null && + (state.selectedSurahId != null || state.isRandomEnabled); + } + + /// Saves the current state to SharedPreferences. + Future _savePreferences(ScheduleState currentState) async { + final startTimeString = _formatTimeOfDay(currentState.startTime); + final endTimeString = _formatTimeOfDay(currentState.endTime); + + await Future.wait([ + _prefs.setBool( + BackgroundScheduleAudioServiceConstant.kScheduleEnabled, true), + _prefs.setString( + BackgroundScheduleAudioServiceConstant.kStartTime, startTimeString), + _prefs.setString( + BackgroundScheduleAudioServiceConstant.kEndTime, endTimeString), + _prefs.setString(BackgroundScheduleAudioServiceConstant.kSelectedReciter, + currentState.selectedReciter!.name), + _prefs.setString(BackgroundScheduleAudioServiceConstant.kSelectedMoshaf, + currentState.selectedMoshaf!.id.toString()), + _prefs.setBool(BackgroundScheduleAudioServiceConstant.kRandomEnabled, + currentState.isRandomEnabled), + ]); + + await _savePlaybackPreferences(currentState); + } + + String _formatTimeOfDay(TimeOfDay time) { + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}'; + } + + Future _savePlaybackPreferences(ScheduleState currentState) async { + if (currentState.isRandomEnabled) { + final randomUrls = _generateRandomUrls(); + await Future.wait([ + _prefs.setStringList( + BackgroundScheduleAudioServiceConstant.kRandomUrls, randomUrls), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kSelectedSurah), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kSelectedSurahUrl), + ]); + } else { + await Future.wait([ + _prefs.setInt(BackgroundScheduleAudioServiceConstant.kSelectedSurah, + currentState.selectedSurahId!), + _prefs.setString( + BackgroundScheduleAudioServiceConstant.kSelectedSurahUrl, + currentState.selectedMoshaf!.server), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kRandomUrls), + ]); + } + } + + /// Checks if the current time is within the scheduled range. + bool _isTimeInRange(TimeOfDay current, TimeOfDay start, TimeOfDay end) { + final now = current.hour * 60 + current.minute; + final startMinutes = start.hour * 60 + start.minute; + final endMinutes = end.hour * 60 + end.minute; + + return startMinutes <= endMinutes + ? now >= startMinutes && now < endMinutes + : now >= startMinutes || now < endMinutes; + } + + /// Disables the current schedule and clears all related preferences. + Future _disableSchedule() async { + await Future.wait([ + _prefs.setBool(BackgroundScheduleAudioServiceConstant.kManualPause, true), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kPendingSchedule), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kScheduleEnabled), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kStartTime), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kEndTime), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kSelectedReciter), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kSelectedMoshaf), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kSelectedSurah), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kSelectedSurahUrl), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kRandomEnabled), + _prefs.remove(BackgroundScheduleAudioServiceConstant.kRandomUrls), + ]); + + _service.invoke('kStopAudio'); + await _prefs.reload(); + _service.invoke('update_schedule'); + } + + Future updateReciterList(List reciterList) async { + state = AsyncData(state.value!.copyWith(reciterList: reciterList)); + } +} + +/// Provider for the ScheduleNotifier. +final scheduleProvider = AsyncNotifierProvider( + () => ScheduleNotifier(), +); diff --git a/lib/src/state_management/quran/schedule_listening/schedule_listening_state.dart b/lib/src/state_management/quran/schedule_listening/schedule_listening_state.dart new file mode 100644 index 00000000..10904a95 --- /dev/null +++ b/lib/src/state_management/quran/schedule_listening/schedule_listening_state.dart @@ -0,0 +1,68 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import '../../../domain/model/quran/moshaf_model.dart'; +import '../../../domain/model/quran/reciter_model.dart'; + +class ScheduleState extends Equatable { + final bool isScheduleEnabled; + final TimeOfDay startTime; + final TimeOfDay endTime; + final ReciterModel? selectedReciter; + final MoshafModel? selectedMoshaf; + final int? selectedSurahId; + final bool isRandomEnabled; + final List reciterList; + + const ScheduleState({ + this.isScheduleEnabled = false, + required this.startTime, + required this.endTime, + this.selectedReciter, + this.selectedMoshaf, + this.selectedSurahId, + this.isRandomEnabled = false, + this.reciterList = const [], + }); + + factory ScheduleState.initial() => ScheduleState( + startTime: TimeOfDay(hour: 8, minute: 0), + endTime: TimeOfDay(hour: 20, minute: 0), + ); + + ScheduleState copyWith({ + bool? isScheduleEnabled, + TimeOfDay? startTime, + TimeOfDay? endTime, + ReciterModel? selectedReciter, + MoshafModel? selectedMoshaf, + int? selectedSurahId, + bool? isRandomEnabled, + List? reciterList, + }) { + return ScheduleState( + isScheduleEnabled: isScheduleEnabled ?? this.isScheduleEnabled, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + selectedReciter: selectedReciter ?? this.selectedReciter, + selectedMoshaf: selectedMoshaf ?? this.selectedMoshaf, + // If random is enabled, clear the surah selection + selectedSurahId: (isRandomEnabled ?? this.isRandomEnabled) ? null : (selectedSurahId ?? this.selectedSurahId), + isRandomEnabled: isRandomEnabled ?? this.isRandomEnabled, + reciterList: reciterList ?? this.reciterList, + ); + } + + @override + List get props => [ + isScheduleEnabled, + startTime.hour, + startTime.minute, + endTime.hour, + endTime.minute, + selectedReciter?.id, + selectedMoshaf?.id, + selectedSurahId, + isRandomEnabled, + reciterList.map((r) => r.id).toList(), + ]; +} diff --git a/pubspec.yaml b/pubspec.yaml index d9028a45..5db5fe09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -153,10 +153,11 @@ dev_dependencies: dependency_validator: build_runner: ^2.4.9 http_mock_adapter: ^0.6.1 - + flutter_background_service: ^2.1.3 flutter_lints: 3.0.1 hive_generator: ^2.0.1 mocktail: ^1.0.3 + audio_service: ^0.18.10 dart_mappable_builder: ^4.2.3 json_serializable: ^6.8.0 # For information on the generic Dart part of this file, see the