diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f536a505..41218aa8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -321,6 +321,11 @@ "@noFavoriteReciters": { "description": "Message shown when there are no favorite reciters" }, + "downloadAllSuwarSuccessfully": "The whole quran is downloaded", + "noSuwarDownload": "No new suwars to download", + "connectDownloadQuran":"Please connect to Internet to download", + "playInOnlineModeQuran": "Please connect to internet to play", + "downloaded": "Downloaded", "switchQuranType": "Switch to {name}", "@switchQuranType": { "description": "Message shown when a reciter is added to favorites", diff --git a/lib/src/data/data_source/quran/recite_remote_data_source.dart b/lib/src/data/data_source/quran/recite_remote_data_source.dart index a50daa3d..47f77b6f 100644 --- a/lib/src/data/data_source/quran/recite_remote_data_source.dart +++ b/lib/src/data/data_source/quran/recite_remote_data_source.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'dart:isolate'; import 'package:meta/meta.dart'; import 'package:dio/dio.dart'; @@ -10,6 +11,15 @@ import 'package:mawaqit/src/module/dio_module.dart'; import 'package:mawaqit/src/domain/error/recite_exception.dart'; +import '../../../domain/model/quran/audio_file_model.dart'; + +class _DownloadParams { + final String url; + final SendPort sendPort; + + _DownloadParams(this.url, this.sendPort); +} + class ReciteRemoteDataSource { final Dio _dio; @@ -85,6 +95,56 @@ class ReciteRemoteDataSource { static List _convertSurahListToIntegers(String surahList) { return surahList.split(',').map(int.parse).toList(); } + + static void _downloadAudioFileIsolate(_DownloadParams params) async { + final dio = Dio(); + try { + final response = await dio.get>( + params.url, + options: Options(responseType: ResponseType.bytes), + onReceiveProgress: (received, total) { + if (total != -1) { + final progress = (received / total) * 100; + params.sendPort.send({'progress': progress}); + } + }, + ); + + if (response.statusCode == 200) { + params.sendPort.send({'data': response.data!}); + } else { + params.sendPort.send({'error': 'Failed to fetch audio file'}); + } + } catch (e) { + params.sendPort.send({'error': e.toString()}); + } + + Isolate.exit(); + } + + Future> downloadAudioFile( + AudioFileModel audioFile, + Function(double) onProgress, + ) async { + final receivePort = ReceivePort(); + await Isolate.spawn(_downloadAudioFileIsolate, _DownloadParams(audioFile.url, receivePort.sendPort)); + + List downloadedList = []; + await for (final message in receivePort) { + if (message is Map) { + if (message.containsKey('progress')) { + onProgress(message['progress']); + } else if (message.containsKey('data')) { + downloadedList = message['data']; + break; + } else if (message.containsKey('error')) { + throw (message['error']); + } + } + } + + return downloadedList; + } } final reciteRemoteDataSourceProvider = Provider((ref) { diff --git a/lib/src/data/data_source/quran/reciter_local_data_source.dart b/lib/src/data/data_source/quran/reciter_local_data_source.dart index 113588f5..3121f498 100644 --- a/lib/src/data/data_source/quran/reciter_local_data_source.dart +++ b/lib/src/data/data_source/quran/reciter_local_data_source.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/adapters.dart'; @@ -6,6 +7,12 @@ import 'package:mawaqit/src/const/constants.dart'; import 'package:mawaqit/src/domain/model/quran/reciter_model.dart'; import 'package:mawaqit/src/domain/error/recite_exception.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../../../main.dart'; +import 'package:path/path.dart' as path; + +import '../../../domain/model/quran/audio_file_model.dart'; class ReciteLocalDataSource { final Box _reciterBox; @@ -29,6 +36,57 @@ class ReciteLocalDataSource { } } + Future getSurahPathWithExtension({ + required String reciterId, + required String moshafId, + required String surahNumber, + }) async { + final Directory appDocDir = await getApplicationDocumentsDirectory(); + final String surahPath = '${appDocDir.path}/$reciterId/$moshafId/$surahNumber.mp3'; + return surahPath; + } + + Future isSurahDownloaded({ + required String reciterId, + required String moshafId, + required int surahNumber, + }) async { + try { + final surahFilePath = await getSurahPathWithExtension( + reciterId: reciterId, + moshafId: moshafId, + surahNumber: surahNumber.toString(), + ); + final file = File(surahFilePath); + final exists = await file.exists(); + + log('ReciteLocalDataSource: isSurahDownloaded: Surah $surahNumber ${exists ? 'exists' : 'does not exist'} for reciter $reciterId and riwayah $moshafId'); + return exists; + } catch (e) { + log('ReciteLocalDataSource: error: isSurahDownloaded: ${e.toString()}'); + throw CheckSurahExistenceException(e.toString()); + } + } + + Future saveAudioFile(AudioFileModel audioFileModel, List bytes) async { + try { + final dir = await getApplicationDocumentsDirectory(); + final filePath = path.join(dir.path, audioFileModel.filePath); + final file = File(filePath); + + await file.parent.create(recursive: true); + + // Save the file + await file.writeAsBytes(bytes); + + log('ReciteLocalDataSource: saveAudioFile: Saved audio file at $filePath'); + return filePath; + } catch (e) { + log('ReciteLocalDataSource: saveAudioFile: ${e.toString()}'); + throw SaveAudioFileException(e.toString()); + } + } + Future> getReciters() async { try { final reciters = _reciterBox.values.toList(); @@ -112,6 +170,40 @@ class ReciteLocalDataSource { bool isFavoriteReciter(int reciterId) { return _favoriteReciterBox.values.contains(reciterId); } + + Future getSuwarFolderPath({ + required String reciterId, + required String moshafId, + }) async { + final Directory appDocDir = await getApplicationDocumentsDirectory(); + final String reciterPath = '${appDocDir.path}/$reciterId/$moshafId'; + return reciterPath; + } + + Future> getDownloadedSurahByReciterAndRiwayah({ + required String reciterId, + required String moshafId, + }) async { + List downloadedSuwar = []; + try { + // Get the application documents directory + final path = await getSuwarFolderPath(reciterId: reciterId, moshafId: moshafId); + // Check if the reciter's directory exists + if (await Directory(path).exists()) { + List files = await Directory(path).list().toList(); + + // Filter and collect .mp3 files + for (var file in files) { + if (file is File && file.path.endsWith('.mp3')) { + downloadedSuwar.add(file); + } + } + } + } catch (e) { + logger.e('An error occurred while fetching downloaded surahs: $e'); + } + return downloadedSuwar; + } } final reciteLocalDataSourceProvider = FutureProvider((ref) async { diff --git a/lib/src/data/repository/quran/recite_impl.dart b/lib/src/data/repository/quran/recite_impl.dart index 5466f906..fffa1661 100644 --- a/lib/src/data/repository/quran/recite_impl.dart +++ b/lib/src/data/repository/quran/recite_impl.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mawaqit/src/data/data_source/quran/recite_remote_data_source.dart'; import 'package:mawaqit/src/data/data_source/quran/reciter_local_data_source.dart'; @@ -6,6 +8,8 @@ import 'package:mawaqit/src/domain/model/quran/reciter_model.dart'; import 'package:mawaqit/src/domain/repository/quran/recite_repository.dart'; +import '../../../domain/model/quran/audio_file_model.dart'; + class ReciteImpl implements ReciteRepository { final ReciteRemoteDataSource _remoteDataSource; final ReciteLocalDataSource _localDataSource; @@ -50,6 +54,50 @@ class ReciteImpl implements ReciteRepository { Future clearAllReciters() async { await _localDataSource.clearAllReciters(); } + + @override + Future getLocalSurahPath({ + required String reciterId, + required String moshafId, + required String surahNumber, + }) async { + return await _localDataSource.getSurahPathWithExtension( + moshafId: moshafId, + surahNumber: surahNumber, + reciterId: reciterId, + ); + } + + @override + Future isSurahDownloaded({ + required String reciterId, + required String moshafId, + required int surahNumber, + }) async { + return await _localDataSource.isSurahDownloaded( + reciterId: reciterId, + moshafId: moshafId, + surahNumber: surahNumber, + ); + } + + @override + Future downloadAudio(AudioFileModel audioFile, Function(double p1) onProgress) async { + final downloadedList = await _remoteDataSource.downloadAudioFile(audioFile, onProgress); + final path = await _localDataSource.saveAudioFile(audioFile, downloadedList); + return path; + } + + @override + Future> getDownloadedSuwarByReciterAndRiwayah({ + required String reciterId, + required String moshafId, + }) async { + return _localDataSource.getDownloadedSurahByReciterAndRiwayah( + moshafId: moshafId, + reciterId: reciterId, + ); + } } final reciteImplProvider = FutureProvider((ref) async { diff --git a/lib/src/domain/error/recite_exception.dart b/lib/src/domain/error/recite_exception.dart index bc36bfd2..029a235c 100644 --- a/lib/src/domain/error/recite_exception.dart +++ b/lib/src/domain/error/recite_exception.dart @@ -96,3 +96,32 @@ class FetchFavoriteRecitersException implements Exception { final String message; FetchFavoriteRecitersException(this.message); } + +class FetchAudioFileFailedException extends ReciterException { + FetchAudioFileFailedException(String message) + : super('Error occurred while fetching audio file: $message', 'FETCH_AUDIO_FILE_ERROR'); +} + +class CheckSurahExistenceException extends ReciterException { + CheckSurahExistenceException(String message) + : super('Error occurred while saving audio file: $message', 'CHECK_SURAH_EXISTENCE_ERROR'); +} + +class SaveAudioFileException extends ReciterException { + SaveAudioFileException(String message) + : super('Error occurred while saving audio file: $message', 'SAVE_AUDIO_FILE_ERROR'); +} + +class FetchAudioFileException extends ReciterException { + FetchAudioFileException(String message) + : super('Error occurred while fetching audio file: $message', 'FETCH_AUDIO_FILE_ERROR'); +} + +class AudioFileNotFoundInCacheException extends ReciterException { + AudioFileNotFoundInCacheException() : super('Audio file not found in cache', 'AUDIO_FILE_NOT_FOUND_IN_CACHE_ERROR'); +} + +class FetchLocalAudioFileException extends ReciterException { + FetchLocalAudioFileException(String message) + : super('Audio file not found in local $message', 'AUDIO_FILE_NOT_FOUND_LOCAL_ERROR'); +} diff --git a/lib/src/domain/model/quran/audio_file_model.dart b/lib/src/domain/model/quran/audio_file_model.dart new file mode 100644 index 00000000..d6d50b0e --- /dev/null +++ b/lib/src/domain/model/quran/audio_file_model.dart @@ -0,0 +1,49 @@ +import 'package:dart_mappable/dart_mappable.dart'; + +part 'audio_file_model.mapper.dart'; + +@MappableClass() +class AudioFileModel { + final String reciterId; + final String moshafId; + final String surahId; + final String url; + + AudioFileModel(this.reciterId, this.moshafId, this.surahId, this.url); + + String get filePath => '$reciterId/$moshafId/$surahId.mp3'; + + factory AudioFileModel.fromJson(Map map) => _ensureContainer.fromMap(map); + + factory AudioFileModel.fromString(String json) => _ensureContainer.fromJson(json); + + Map toJson() { + return _ensureContainer.toMap(this); + } + + @override + String toString() { + return _ensureContainer.toJson(this); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || (runtimeType == other.runtimeType && _ensureContainer.isEqual(this, other)); + } + + @override + int get hashCode { + return _ensureContainer.hash(this); + } + + AudioFileModelCopyWith get copyWith { + return _AudioFileModelCopyWithImpl(this, $identity, $identity); + } + + static final MapperContainer _ensureContainer = () { + AudioFileModelMapper.ensureInitialized(); + return MapperContainer.globals; + }(); + + static AudioFileModelMapper ensureInitialized() => AudioFileModelMapper.ensureInitialized(); +} diff --git a/lib/src/pages/quran/page/quran_player_screen.dart b/lib/src/pages/quran/page/quran_player_screen.dart index 776020dd..df3083d0 100644 --- a/lib/src/pages/quran/page/quran_player_screen.dart +++ b/lib/src/pages/quran/page/quran_player_screen.dart @@ -1,20 +1,36 @@ import 'dart:developer'; +import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:mawaqit/src/domain/model/quran/moshaf_model.dart'; +import 'package:mawaqit/src/domain/model/quran/surah_model.dart'; import 'package:mawaqit/src/pages/quran/page/surah_selection_screen.dart'; import 'package:mawaqit/src/pages/quran/widget/quran_player/seek_bar.dart'; +import 'package:mawaqit/src/state_management/quran/recite/download_audio_quran/download_audio_quran_notifier.dart'; +import 'package:mawaqit/src/state_management/quran/recite/download_audio_quran/download_audio_quran_state.dart'; import 'package:mawaqit/src/state_management/quran/recite/quran_audio_player_notifier.dart'; +import 'package:percent_indicator/circular_percent_indicator.dart'; import 'package:rxdart/rxdart.dart' as rxdart; import 'package:mawaqit/const/resource.dart'; import 'package:sizer/sizer.dart'; import 'package:mawaqit/src/state_management/quran/recite/quran_audio_player_state.dart'; import 'package:mawaqit/src/pages/quran/widget/quran_background.dart'; -import 'dart:math' as math; class QuranPlayerScreen extends ConsumerStatefulWidget { - const QuranPlayerScreen({super.key}); + final String reciterId; + final MoshafModel selectedMoshaf; + final SurahModel surah; + + const QuranPlayerScreen({ + super.key, + required this.reciterId, + required this.selectedMoshaf, + required this.surah, + }); @override ConsumerState createState() => _QuranPlayerScreenState(); @@ -39,11 +55,12 @@ class _QuranPlayerScreenState extends ConsumerState { } Stream get _seekBarDataStream => rxdart.Rx.combineLatest2( - ref.read(quranPlayerNotifierProvider.notifier).positionStream, - ref.read(quranPlayerNotifierProvider.notifier).audioPlayer.durationStream, - (Duration position, Duration? duration) { - return SeekBarData(position, duration ?? Duration.zero); - }); + ref.read(quranPlayerNotifierProvider.notifier).positionStream, + ref.read(quranPlayerNotifierProvider.notifier).audioPlayer.durationStream, + (Duration position, Duration? duration) { + return SeekBarData(position, duration ?? Duration.zero); + }, + ); @override Widget build(BuildContext context) { @@ -60,20 +77,13 @@ class _QuranPlayerScreenState extends ConsumerState { backgroundColor: Colors.transparent, elevation: 0, systemOverlayStyle: SystemUiOverlayStyle.light, - leading: Focus( - focusNode: backButtonFocusNode, - onFocusChange: (hasFocus) { - if (hasFocus) { - log('Back button focused'); - } + leading: InkWell( + borderRadius: BorderRadius.circular(20.sp), + child: Icon(Icons.arrow_back), + onTap: () { + ref.read(quranPlayerNotifierProvider.notifier).stop(); + Navigator.of(context).pop(); }, - child: IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () { - ref.read(quranPlayerNotifierProvider.notifier).stop(); - Navigator.of(context).pop(); - }, - ), ), ), screen: quranPlayerState.maybeWhen( @@ -93,6 +103,9 @@ class _QuranPlayerScreenState extends ConsumerState { surahType: quranPlayerState.reciterName, seekBarDataStream: _seekBarDataStream, onFocusBackButton: () => backButtonFocusNode.requestFocus(), + selectedMoshaf: widget.selectedMoshaf, + reciterId: widget.reciterId, + surah: widget.surah, ), bottom: 0, left: 0, @@ -116,12 +129,18 @@ class _QuranPlayer extends ConsumerStatefulWidget { required this.isPlaying, required this.onFocusBackButton, required this.backButtonFocusNode, + required this.selectedMoshaf, + required this.reciterId, + required this.surah, }) : _seekBarDataStream = seekBarDataStream; final VoidCallback onFocusBackButton; final String surahName; final String surahType; final bool isPlaying; + final MoshafModel selectedMoshaf; + final String reciterId; + final SurahModel surah; final FocusNode backButtonFocusNode; final Stream _seekBarDataStream; @@ -137,6 +156,7 @@ class _QuranPlayerState extends ConsumerState<_QuranPlayer> { late final FocusNode sliderFocusNode; late final FocusScopeNode volumeFocusNode; late final FocusNode playFocusNode; + late final FocusNode downloadFocusNode; Color _sliderThumbColor = Colors.white; Color _volumeSliderThumbColor = Colors.white; @@ -151,6 +171,7 @@ class _QuranPlayerState extends ConsumerState<_QuranPlayer> { volumeFocusNode = FocusScopeNode(); playFocusNode = FocusNode(); sliderFocusNode = FocusNode(); + downloadFocusNode = FocusNode(); sliderFocusNode.addListener(_setSliderThumbColor); volumeFocusNode.addListener(_setVolumeSliderThumbColor); @@ -180,6 +201,7 @@ class _QuranPlayerState extends ConsumerState<_QuranPlayer> { rightFocusNode.dispose(); sliderFocusNode.dispose(); playFocusNode.dispose(); + downloadFocusNode.dispose(); super.dispose(); } @@ -212,6 +234,33 @@ class _QuranPlayerState extends ConsumerState<_QuranPlayer> { ), ), SizedBox(height: 4.h), + Row( + children: [ + Spacer(), + Consumer( + builder: (context, ref, child) { + final quranPlayer = ref.watch( + downloadStateProvider( + DownloadStateProviderParameter( + reciterId: widget.reciterId, + moshafId: widget.selectedMoshaf.id.toString(), + ), + ), + ); + + final isDownloaded = quranPlayer.downloadedSuwar.contains(widget.surah.id); + + final findFirstDownloadedSurah = quranPlayer.downloadingSuwar.firstWhereOrNull( + (element) => element.surahId == widget.surah.id, + ); + return downloadingWidget( + isDownloaded, + Option.fromNullable(findFirstDownloadedSurah), + ); + }, + ), + ], + ), StreamBuilder( stream: widget._seekBarDataStream, builder: (context, snapshot) { @@ -371,135 +420,95 @@ class _QuranPlayerState extends ConsumerState<_QuranPlayer> { ], ), ), - FocusableActionDetector( - focusNode: leftFocusNode, - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), - }, - actions: { - ActivateIntent: CallbackAction( - onInvoke: (intent) { - final notifier = ref.read(quranPlayerNotifierProvider.notifier); - notifier.seekToPrevious(); - return null; - }, - ), - }, - onFocusChange: (hasFocus) { - setState(() {}); - }, - child: Container( - decoration: BoxDecoration( - color: leftFocusNode.hasFocus ? theme.primaryColor : Colors.transparent, - shape: BoxShape.circle, - ), - child: IconButton( - icon: SvgPicture.asset( - directionality != TextDirection.ltr - ? R.ASSETS_ICON_SKIP_NEXT_SVG - : R.ASSETS_ICON_SKIP_PREVIOUS_SVG, - color: Colors.white, - width: 6.w, - ), - iconSize: 8.w, - onPressed: () { - leftFocusNode.requestFocus(); - final notifier = ref.read(quranPlayerNotifierProvider.notifier); - notifier.seekToPrevious(); - }, - ), + InkWell( + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return Container( + decoration: BoxDecoration( + color: isFocused ? theme.primaryColor : Colors.transparent, + shape: BoxShape.circle, + ), + child: IconButton( + icon: SvgPicture.asset( + directionality != TextDirection.ltr + ? R.ASSETS_ICON_SKIP_NEXT_SVG + : R.ASSETS_ICON_SKIP_PREVIOUS_SVG, + color: Colors.white, + width: 6.w, + ), + iconSize: 8.w, + onPressed: () { + final notifier = ref.read(quranPlayerNotifierProvider.notifier); + notifier.seekToPrevious(); + }, + ), + ); + }, ), ), - FocusableActionDetector( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), - }, - actions: { - ActivateIntent: CallbackAction( - onInvoke: (intent) { - final notifier = ref.read(quranPlayerNotifierProvider.notifier); - if (widget.isPlaying) { - notifier.pause(); - } else { - notifier.play(); - } - return null; - }, - ), - }, - focusNode: playFocusNode, - onFocusChange: (hasFocus) { - setState(() {}); - }, - child: Container( - decoration: BoxDecoration( - color: playFocusNode.hasFocus ? theme.primaryColor : Colors.transparent, - shape: BoxShape.circle, - ), - child: IconButton( - icon: widget.isPlaying - ? SvgPicture.asset( - R.ASSETS_ICON_PAUSE_SVG, - color: Colors.white, - ) - : Transform.rotate( - angle: directionality == TextDirection.rtl ? math.pi : 0, - child: Icon( - Icons.play_arrow, - color: Colors.white, - size: 8.w, - ), - ), - iconSize: 10.w, - onPressed: () { - final notifier = ref.read(quranPlayerNotifierProvider.notifier); - if (widget.isPlaying) { - notifier.pause(); - } else { - notifier.play(); - } - playFocusNode.requestFocus(); - }, - ), + InkWell( + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return Container( + decoration: BoxDecoration( + color: isFocused ? theme.primaryColor : Colors.transparent, + shape: BoxShape.circle, + ), + child: IconButton( + icon: widget.isPlaying + ? SvgPicture.asset( + R.ASSETS_ICON_PAUSE_SVG, + color: Colors.white, + ) + : Transform.rotate( + angle: directionality == TextDirection.rtl ? math.pi : 0, + child: Icon( + Icons.play_arrow, + color: Colors.white, + size: 8.w, + ), + ), + iconSize: 10.w, + onPressed: () { + final notifier = ref.read(quranPlayerNotifierProvider.notifier); + if (widget.isPlaying) { + notifier.pause(); + } else { + notifier.play(); + } + }, + ), + ); + }, ), ), - FocusableActionDetector( - focusNode: rightFocusNode, - onFocusChange: (hasFocus) { - setState(() {}); - }, - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), - }, - actions: { - ActivateIntent: CallbackAction( - onInvoke: (intent) { - final notifier = ref.read(quranPlayerNotifierProvider.notifier); - notifier.seekToNext(); - return null; - }, - ), - }, - child: Container( - decoration: BoxDecoration( - color: rightFocusNode.hasFocus ? theme.primaryColor : Colors.transparent, - shape: BoxShape.circle, - ), - child: IconButton( - icon: SvgPicture.asset( - directionality == TextDirection.ltr - ? R.ASSETS_ICON_SKIP_NEXT_SVG - : R.ASSETS_ICON_SKIP_PREVIOUS_SVG, - color: Colors.white, - width: 6.w, - ), - iconSize: 8.w, - onPressed: () { - rightFocusNode.requestFocus(); - final notifier = ref.read(quranPlayerNotifierProvider.notifier); - notifier.seekToNext(); - }, - ), + InkWell( + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return Container( + decoration: BoxDecoration( + color: isFocused ? theme.primaryColor : Colors.transparent, + shape: BoxShape.circle, + ), + child: IconButton( + icon: SvgPicture.asset( + directionality == TextDirection.ltr + ? R.ASSETS_ICON_SKIP_NEXT_SVG + : R.ASSETS_ICON_SKIP_PREVIOUS_SVG, + color: Colors.white, + width: 6.w, + ), + iconSize: 8.w, + onPressed: () { + final notifier = ref.read(quranPlayerNotifierProvider.notifier); + notifier.seekToNext(); + }, + ), + ); + }, ), ), Expanded( @@ -645,6 +654,56 @@ class _QuranPlayerState extends ConsumerState<_QuranPlayer> { ), ); } + + Widget downloadingWidget(bool isDownloaded, Option downloadProgress) { + return downloadProgress.fold( + () => InkWell( + focusColor: Colors.grey, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isFocused ? Color(0xFF490094) : Colors.transparent, + ), + child: IconButton( + icon: Icon( + isDownloaded ? Icons.download_done : Icons.download, + color: Colors.white, + ), + onPressed: isDownloaded + ? null + : () async { + await ref.read(quranPlayerNotifierProvider.notifier).downloadAudio( + reciterId: widget.reciterId, + moshafId: widget.selectedMoshaf.id.toString(), + surahId: widget.surah.id, + url: widget.surah.getSurahUrl( + widget.selectedMoshaf.server, + ), + ); + }, + ), + ); + }, + ), + ), + (t) { + return CircularPercentIndicator( + radius: 15.sp, + lineWidth: 3.sp, + percent: t.progress, + progressColor: Colors.white, + backgroundColor: Colors.white.withOpacity(0.3), + center: Text( + '${(t.progress * 100).toInt()}%', + style: TextStyle(color: Colors.white, fontSize: 8.sp), + ), + ); + }, + ); + } } class _BackgroundFilter extends StatelessWidget { diff --git a/lib/src/pages/quran/page/surah_selection_screen.dart b/lib/src/pages/quran/page/surah_selection_screen.dart index ab05296c..1b98d7a9 100644 --- a/lib/src/pages/quran/page/surah_selection_screen.dart +++ b/lib/src/pages/quran/page/surah_selection_screen.dart @@ -1,27 +1,37 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mawaqit/src/domain/model/quran/moshaf_model.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:mawaqit/src/domain/model/quran/surah_model.dart'; -import 'package:mawaqit/src/pages/quran/widget/quran_background.dart'; import 'package:mawaqit/src/pages/quran/widget/surah_card.dart'; import 'package:mawaqit/src/state_management/quran/quran/quran_notifier.dart'; -import 'package:mawaqit/src/state_management/quran/recite/recite_notifier.dart'; import 'package:shimmer/shimmer.dart'; import 'package:mawaqit/src/state_management/quran/recite/quran_audio_player_notifier.dart'; import 'package:mawaqit/src/pages/quran/page/quran_player_screen.dart'; import 'package:sizer/sizer.dart'; +import '../../../../const/resource.dart'; +import '../../../../i18n/l10n.dart'; +import '../../../domain/model/quran/moshaf_model.dart'; +import '../../../helpers/connectivity_provider.dart'; +import '../../../models/address_model.dart'; +import '../../../services/theme_manager.dart'; +import '../../../state_management/quran/recite/download_audio_quran/download_audio_quran_notifier.dart'; +import '../../../state_management/quran/recite/download_audio_quran/download_audio_quran_state.dart'; + class SurahSelectionScreen extends ConsumerStatefulWidget { final MoshafModel selectedMoshaf; + final String reciterId; const SurahSelectionScreen({ - required this.selectedMoshaf, super.key, + required this.selectedMoshaf, + required this.reciterId, }); @override @@ -32,187 +42,277 @@ class _SurahSelectionScreenState extends ConsumerState { int selectedIndex = 0; final int _crossAxisCount = 4; final ScrollController _scrollController = ScrollController(); - late FocusNode _searchFocusNode; + Timer? _debounceTimer; + bool _isNavigating = false; @override void initState() { super.initState(); - _searchFocusNode = FocusNode(); - RawKeyboard.instance.addListener(_handleKeyEvent); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(quranPlayerNotifierProvider.notifier).getDownloadedSuwarByReciterAndRiwayah( + reciterId: widget.reciterId, + moshafId: widget.selectedMoshaf.id.toString(), + ); + }); } @override void dispose() { - RawKeyboard.instance.removeListener(_handleKeyEvent); - _searchFocusNode = FocusNode(); _scrollController.dispose(); super.dispose(); } + void showToast(String message) { + Fluttertoast.showToast( + msg: message, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 1, + backgroundColor: Colors.black87, + textColor: Colors.white, + fontSize: 16.0, + ); + } + + void _debouncedNavigation(BuildContext context, SurahModel surah, List suwar) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + if (!_isNavigating) { + _isNavigating = true; + _navigateToQuranPlayerScreen(context, surah, suwar); + } + }); + } + + void _navigateToQuranPlayerScreen(BuildContext context, SurahModel surah, List suwar) { + ref.read(quranPlayerNotifierProvider.notifier).initialize( + moshaf: widget.selectedMoshaf, + surah: surah, + suwar: suwar, + reciterId: widget.reciterId, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => QuranPlayerScreen( + reciterId: widget.reciterId, + selectedMoshaf: widget.selectedMoshaf, + surah: surah, + ), + ), + ).then((_) { + _isNavigating = false; + }); + } + + String _getKey() { + return "${widget.reciterId}:${widget.selectedMoshaf.id.toString()}"; + } + @override Widget build(BuildContext context) { + final downloadNotifierParameter = DownloadStateProviderParameter( + reciterId: widget.reciterId, + moshafId: widget.selectedMoshaf.id.toString(), + ); + final quranState = ref.watch(quranNotifierProvider); - ref.listen(navigateIntoNewPageProvider, (previous, next) { - if (next) { - RawKeyboard.instance.removeListener(_handleKeyEvent); - } else { - RawKeyboard.instance.addListener(_handleKeyEvent); + ref.listen(downloadStateProvider(downloadNotifierParameter), (previous, next) { + if (next.downloadStatus == DownloadStatus.completed) { + showToast(S.of(context).downloadAllSuwarSuccessfully); + ref + .read( + downloadStateProvider(downloadNotifierParameter).notifier, + ) + .resetDownloadStatus(); + } else if (next.downloadStatus == DownloadStatus.noNewDownloads) { + showToast(S.of(context).noSuwarDownload); + ref.read(downloadStateProvider(downloadNotifierParameter).notifier).resetDownloadStatus(); } }); - return QuranBackground( - isSwitch: false, - screen: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ExcludeFocus( - child: IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () { - Navigator.pop(context); - }, - ), - ), - SizedBox(height: 10), - Expanded( - child: quranState.when( - data: (data) { - return GridView.builder( - padding: EdgeInsets.symmetric(horizontal: 3.w), - controller: _scrollController, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _crossAxisCount, - childAspectRatio: 1.8, - crossAxisSpacing: 20, - mainAxisSpacing: 20, - ), - itemCount: data.suwar.length, - itemBuilder: (context, index) { - return SurahCard( - surahName: data.suwar[index].name, - surahNumber: data.suwar[index].id, - isSelected: index == selectedIndex, - onTap: () { - setState(() { - selectedIndex = index; - }); - final moshaf = ref.read(reciteNotifierProvider).maybeWhen( - orElse: () => null, - data: (data) => data.selectedMoshaf, - ); - ref.read(quranPlayerNotifierProvider.notifier).initialize( - moshaf: widget.selectedMoshaf, - surah: data.suwar[index], - suwar: data.suwar, - ); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => QuranPlayerScreen(), - ), + return Scaffold( + appBar: AppBar( + backgroundColor: Color(0xFF28262F), + elevation: 0, + actions: [ + IconButton( + splashRadius: 12.sp, + iconSize: 14.sp, + focusColor: Theme.of(context).primaryColor, + onPressed: ref + .watch( + downloadStateProvider(downloadNotifierParameter), + ) + .downloadStatus != + DownloadStatus.downloading + ? () async { + await ref.read(connectivityProvider.notifier).checkInternetConnection(); + if (ref.read(connectivityProvider).value == ConnectivityStatus.connected) { + quranState.whenOrNull( + data: (data) { + ref.read(quranPlayerNotifierProvider.notifier).downloadAllSuwar( + reciterId: widget.reciterId.toString(), + moshaf: widget.selectedMoshaf, + moshafId: widget.selectedMoshaf.id.toString(), + suwar: data.suwar, ); - _scrollToSelectedItem(); - }, - ); }, ); - }, - error: (error, stack) { - log('Error: $error\n$stack'); - return Center( - child: Text( - 'Error: $error', - ), - ); - }, - loading: () => _buildShimmerGrid(), - ), - ), - ], + } else { + showToast(S.of(context).connectDownloadQuran); + } + } + : null, + icon: Icon( + Icons.download, ), - ), + ) ], + leading: IconButton( + splashRadius: 12.sp, + icon: Icon( + Icons.arrow_back, + color: Colors.white, + ), + onPressed: () { + Navigator.pop(context); + }, + ), ), - ); - } - - void _handleKeyEvent(RawKeyEvent event) { - final surahs = ref.read(quranNotifierProvider).maybeWhen(orElse: () => [], data: (data) => data.suwar); - final textDirection = Directionality.of(context); - - if (event is RawKeyDownEvent) { - log('Key pressed: ${event.logicalKey}'); - if (event.logicalKey == LogicalKeyboardKey.select) { - _searchFocusNode.requestFocus(); - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - setState(() { - if (textDirection == TextDirection.ltr) { - selectedIndex = (selectedIndex + 1) % surahs.length; - } else { - selectedIndex = (selectedIndex - 1 + surahs.length) % surahs.length; - } - }); - _scrollToSelectedItem(); - } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - setState(() { - if (textDirection == TextDirection.ltr) { - selectedIndex = (selectedIndex - 1) % surahs.length; - } else { - selectedIndex = (selectedIndex + 1 + surahs.length) % surahs.length; - } - }); - _scrollToSelectedItem(); - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - if (selectedIndex < _crossAxisCount) { - // _searchFocusNode.requestFocus(); - } else { - setState(() { - selectedIndex = (selectedIndex - _crossAxisCount + surahs.length) % surahs.length; - }); - _scrollToSelectedItem(); - } - } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - setState(() { - selectedIndex = (selectedIndex + _crossAxisCount) % surahs.length; - }); - _scrollToSelectedItem(); - } else if (event.logicalKey == LogicalKeyboardKey.select) { - _handleSurahSelection(surahs[selectedIndex]); - } - } - } - - void _handleSurahSelection(SurahModel selectedSurah) { - final moshaf = ref.read(reciteNotifierProvider).maybeWhen( - orElse: () => null, - data: (data) => data.selectedMoshaf, - ); - final quranState = ref.read(quranNotifierProvider); + body: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(R.ASSETS_BACKGROUNDS_QURAN_BACKGROUND_PNG), + fit: BoxFit.cover, + ), + gradient: ThemeNotifier.quranBackground(), + ), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // IconButton( + // icon: Icon(Icons.arrow_back), + // onPressed: () { + // Navigator.pop(context); + // }, + // ), + // Material( + // borderRadius: BorderRadius.circular(25.sp), + // color: Colors.white.withOpacity(0.2), + // child: InkWell( + // onTap: ref.watch(downloadStateProvider).downloadStatus != DownloadStatus.downloading + // ? () async { + // await ref.read(connectivityProvider.notifier).checkInternetConnection(); + // if (ref.read(connectivityProvider).value == ConnectivityStatus.connected) { + // quranState.whenOrNull( + // data: (data) { + // ref.read(quranPlayerNotifierProvider.notifier).downloadAllSuwar( + // reciterId: widget.reciterId.toString(), + // moshaf: widget.selectedMoshaf, + // moshafId: widget.selectedMoshaf.id.toString(), + // suwar: data.suwar, + // ); + // }, + // ); + // } else { + // showToast(S.of(context).connectDownloadQuran); + // } + // } + // : null, + // focusColor: Theme.of(context).primaryColor, + // child: Icon( + // Icons.download, + // size: 22.sp, + // ), + // ), + // ), + ], + ), + SizedBox(height: 10), + Expanded( + child: quranState.when( + data: (data) { + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 3.w), + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _crossAxisCount, + childAspectRatio: 1.8, + crossAxisSpacing: 15, + mainAxisSpacing: 15, + ), + itemCount: data.suwar.length, + itemBuilder: (context, index) { + final downloadState = ref.watch( + downloadStateProvider(downloadNotifierParameter), + ); + final reciterMoshafState = downloadState; - quranState.maybeWhen( - orElse: () {}, - data: (data) { - ref.read(quranPlayerNotifierProvider.notifier).initialize( - moshaf: widget.selectedMoshaf, - surah: selectedSurah, - suwar: data.suwar, - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(navigateIntoNewPageProvider.notifier).state = true; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => QuranPlayerScreen(), - ), - ).then((_) { - ref.read(navigateIntoNewPageProvider.notifier).state = false; - }); - }); - }, + final isDownloaded = reciterMoshafState.downloadedSuwar.contains(data.suwar[index].id); + final downloadProgress = reciterMoshafState.downloadingSuwar + .firstWhere( + (info) => info.surahId == data.suwar[index].id, + orElse: () => SurahDownloadInfo(surahId: data.suwar[index].id, progress: 0.0), + ) + .progress; + return SurahCard( + index: index, + isDownloaded: isDownloaded, + downloadProgress: downloadProgress, + onDownloadTap: !isDownloaded + ? () async { + await ref.read(connectivityProvider.notifier).checkInternetConnection(); + if (ref.read(connectivityProvider).value == ConnectivityStatus.connected) { + ref.read(quranPlayerNotifierProvider.notifier).downloadAudio( + reciterId: widget.reciterId, + moshafId: widget.selectedMoshaf.id.toString(), + surahId: data.suwar[index].id, + url: data.suwar[index].getSurahUrl(widget.selectedMoshaf.server), + ); + } else { + showToast(S.of(context).connectDownloadQuran); + } + } + : null, + surahName: data.suwar[index].name, + surahNumber: data.suwar[index].id, + onTap: () async { + await ref.read(connectivityProvider.notifier).checkInternetConnection(); + setState(() { + selectedIndex = index; + }); + if ((ref.read(connectivityProvider).hasValue && + ref.read(connectivityProvider).value == ConnectivityStatus.connected) || + isDownloaded) { + _debouncedNavigation(context, data.suwar[index], data.suwar); + } else { + showToast(S.of(context).playInOnlineModeQuran); + } + }, + ); + }, + ); + }, + error: (error, stack) { + log('Error: $error\n$stack'); + return Center( + child: Text( + 'Error: $error', + ), + ); + }, + loading: () => _buildShimmerGrid(), + ), + ), + ], + ), + ), + ), ); } @@ -239,18 +339,6 @@ class _SurahSelectionScreenState extends ConsumerState { }, ); } - - void _scrollToSelectedItem() { - final surahs = ref.read(quranNotifierProvider).maybeWhen(orElse: () => [], data: (data) => data.suwar); - final int rowIndex = selectedIndex ~/ _crossAxisCount; - final double itemHeight = _scrollController.position.maxScrollExtent / ((surahs.length - 1) / _crossAxisCount); - final double targetOffset = rowIndex * itemHeight; - _scrollController.animateTo( - targetOffset, - duration: Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - } } final navigateIntoNewPageProvider = StateProvider.autoDispose((ref) => false); diff --git a/lib/src/pages/quran/widget/recite_type_grid_view.dart b/lib/src/pages/quran/widget/recite_type_grid_view.dart index 9f1b67ae..4cb10f7a 100644 --- a/lib/src/pages/quran/widget/recite_type_grid_view.dart +++ b/lib/src/pages/quran/widget/recite_type_grid_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:mawaqit/src/domain/model/quran/moshaf_model.dart'; +import 'package:mawaqit/src/domain/model/quran/reciter_model.dart'; import 'package:mawaqit/src/pages/quran/page/surah_selection_screen.dart'; import 'package:mawaqit/src/state_management/quran/quran/quran_notifier.dart'; import 'package:mawaqit/src/state_management/quran/recite/recite_notifier.dart'; @@ -13,6 +15,7 @@ class ReciteTypeGridView extends ConsumerStatefulWidget { }); final List reciterTypes; + @override ConsumerState createState() => _ReciteTypeGridViewState(); } @@ -49,11 +52,20 @@ class _ReciteTypeGridViewState extends ConsumerState { ref.read(quranNotifierProvider.notifier).getSuwarByReciter( selectedMoshaf: widget.reciterTypes[selectedReciteTypeIndex], ); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SurahSelectionScreen( - selectedMoshaf: widget.reciterTypes[selectedReciteTypeIndex], + + final Option selectedReciterId = ref.watch(reciteNotifierProvider).maybeWhen( + orElse: () => none(), + data: (reciterState) => reciterState.selectedReciter, + ); + selectedReciterId.fold( + () => null, + (reciter) => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SurahSelectionScreen( + reciterId: reciter.id.toString(), + selectedMoshaf: widget.reciterTypes[selectedReciteTypeIndex], + ), ), ), ); diff --git a/lib/src/pages/quran/widget/surah_card.dart b/lib/src/pages/quran/widget/surah_card.dart index 6811f4a0..a10c099f 100644 --- a/lib/src/pages/quran/widget/surah_card.dart +++ b/lib/src/pages/quran/widget/surah_card.dart @@ -1,58 +1,177 @@ import 'package:flutter/material.dart'; -import 'package:mawaqit/src/helpers/RelativeSizes.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mawaqit/i18n/l10n.dart'; import 'package:sizer/sizer.dart'; -class SurahCard extends StatelessWidget { +class SurahCard extends ConsumerStatefulWidget { final String surahName; final int surahNumber; final int? verses; - final bool isSelected; final VoidCallback onTap; + final bool isDownloaded; + final double downloadProgress; + final VoidCallback? onDownloadTap; + final int index; const SurahCard({ required this.surahName, required this.surahNumber, - required this.isSelected, required this.onTap, + required this.index, + this.isDownloaded = false, + required this.downloadProgress, + required this.onDownloadTap, this.verses, }); + @override + ConsumerState createState() => _SurahCardState(); +} + +class _SurahCardState extends ConsumerState with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _scaleAnimation = Tween(begin: 1.0, end: 1.05).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: isSelected ? Colors.white.withOpacity(0.4) : Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(14), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '$surahNumber. $surahName', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - verses == null - ? Container() - : Text( - '$verses verses', - style: TextStyle( - fontSize: 14, - color: Colors.white70, + return InkWell( + autofocus: widget.index == 0, + onTap: widget.onTap, + onHover: (isHovering) { + if (isHovering) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }, + onFocusChange: (hasFocus) { + if (hasFocus) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }, + child: AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Builder( + builder: (context) { + return Stack( + children: [ + Container( + decoration: BoxDecoration( + color: + Focus.of(context).hasFocus ? Colors.white.withOpacity(0.4) : Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${widget.surahNumber}. ${widget.surahName}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10.sp, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + if (widget.verses != null) SizedBox(height: 8), + if (widget.verses != null) + Text( + '${widget.verses} verses', + style: TextStyle( + fontSize: 10.sp, + color: Colors.white70, + ), + ), + ], + ), ), ), - ], - ), - ), + Positioned( + top: 0, + right: 0, + child: _buildDownloadTag(), + ), + ], + ); + }, + ), + ); + }, ), ); } + + Widget _buildDownloadTag() { + if (widget.isDownloaded && widget.downloadProgress == 0) { + return InkWell( + onTap: widget.onDownloadTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 5.sp, vertical: 2.sp), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.only( + topRight: Radius.circular(14), + bottomLeft: Radius.circular(14), + ), + ), + child: Text( + S.of(context).downloaded, + style: TextStyle( + color: Colors.white, + fontSize: 8.sp, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } else if (!widget.isDownloaded && widget.downloadProgress > 0 && widget.downloadProgress < 1) { + return InkWell( + onTap: widget.onDownloadTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 5.sp, vertical: 2.sp), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.only( + topRight: Radius.circular(14), + bottomLeft: Radius.circular(14), + ), + ), + child: Text( + '${(widget.downloadProgress * 100).toInt()}%', + style: TextStyle( + color: Colors.white, + fontSize: 8.sp, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } else { + return Container(); + } + } } diff --git a/lib/src/state_management/quran/recite/download_audio_quran/download_audio_quran_notifier.dart b/lib/src/state_management/quran/recite/download_audio_quran/download_audio_quran_notifier.dart new file mode 100644 index 00000000..9638b53a --- /dev/null +++ b/lib/src/state_management/quran/recite/download_audio_quran/download_audio_quran_notifier.dart @@ -0,0 +1,83 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mawaqit/src/state_management/quran/recite/download_audio_quran/download_audio_quran_state.dart'; + +class DownloadStateNotifier extends FamilyNotifier { + @override + DownloadAudioQuranState build(DownloadStateProviderParameter arg) { + return DownloadAudioQuranState( + reciterId: arg.reciterId, + moshafId: arg.moshafId, + ); + } + + void setCurrentReciterMoshaf(String reciterId, String moshafId) { + state = state.copyWith( + reciterId: reciterId, + moshafId: moshafId, + ); + } + + Future updateDownloadProgress(int surahId, double progress) async { + final updatedDownloadingSuwar = List.from(state.downloadingSuwar); + final index = updatedDownloadingSuwar.indexWhere((info) => info.surahId == surahId); + if (index != -1) { + updatedDownloadingSuwar[index] = SurahDownloadInfo(surahId: surahId, progress: progress); + } else { + updatedDownloadingSuwar.add(SurahDownloadInfo(surahId: surahId, progress: progress)); + } + + state = state.copyWith( + downloadingSuwar: updatedDownloadingSuwar, + currentDownloadingSurah: surahId, + ); + } + + Future markAsDownloaded(int surahId) async { + final updatedDownloadedSuwar = Set.from(state.downloadedSuwar)..add(surahId); + final updatedDownloadingSuwar = state.downloadingSuwar.where((info) => info.surahId != surahId).toList(); + + state = state.copyWith( + downloadedSuwar: updatedDownloadedSuwar, + downloadingSuwar: updatedDownloadingSuwar, + currentDownloadingSurah: state.currentDownloadingSurah == surahId ? null : state.currentDownloadingSurah, + ); + } + + Future initializeDownloadedSuwar(Set downloadedSuwar) async { + state = state.copyWith(downloadedSuwar: downloadedSuwar); + } + + void setDownloadStatus(DownloadStatus status) { + state = state.copyWith(downloadStatus: status); + } + + void resetDownloadStatus() { + state = state.copyWith(downloadStatus: DownloadStatus.idle); + } +} + +class DownloadStateProviderParameter { + final String reciterId; + final String moshafId; + + DownloadStateProviderParameter({ + required this.reciterId, + required this.moshafId, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DownloadStateProviderParameter && + runtimeType == other.runtimeType && + reciterId == other.reciterId && + moshafId == other.moshafId; + + @override + int get hashCode => reciterId.hashCode ^ moshafId.hashCode; +} + +final downloadStateProvider = + NotifierProviderFamily( + DownloadStateNotifier.new, +); diff --git a/lib/src/state_management/quran/recite/download_audio_quran/download_audio_quran_state.dart b/lib/src/state_management/quran/recite/download_audio_quran/download_audio_quran_state.dart new file mode 100644 index 00000000..34b734b4 --- /dev/null +++ b/lib/src/state_management/quran/recite/download_audio_quran/download_audio_quran_state.dart @@ -0,0 +1,53 @@ +enum DownloadStatus { idle, downloading, completed, noNewDownloads } + +class SurahDownloadInfo { + final int surahId; + final double progress; + + SurahDownloadInfo({required this.surahId, required this.progress}); + + @override + String toString() => 'SurahDownloadInfo(surahId: $surahId, progress: $progress)'; +} + +class DownloadAudioQuranState { + final String reciterId; + final String moshafId; + final List downloadingSuwar; + final Set downloadedSuwar; + final int? currentDownloadingSurah; + final DownloadStatus downloadStatus; + + DownloadAudioQuranState({ + this.reciterId = '', + this.moshafId = '', + this.downloadingSuwar = const [], + this.downloadedSuwar = const {}, + this.currentDownloadingSurah, + this.downloadStatus = DownloadStatus.idle, + }); + + DownloadAudioQuranState copyWith({ + String? reciterId, + String? moshafId, + List? downloadingSuwar, + Set? downloadedSuwar, + int? currentDownloadingSurah, + DownloadStatus? downloadStatus, + }) { + return DownloadAudioQuranState( + reciterId: reciterId ?? this.reciterId, + moshafId: moshafId ?? this.moshafId, + downloadingSuwar: downloadingSuwar ?? this.downloadingSuwar, + downloadedSuwar: downloadedSuwar ?? this.downloadedSuwar, + currentDownloadingSurah: currentDownloadingSurah ?? this.currentDownloadingSurah, + downloadStatus: downloadStatus ?? this.downloadStatus, + ); + } + + @override + String toString() => 'DownloadAudioQuranState(reciterId: $reciterId, ' + 'moshafId: $moshafId, ' + 'downloadingSuwar: $downloadingSuwar, downloadedSuwar: $downloadedSuwar, ' + 'currentDownloadingSurah: $currentDownloadingSurah, downloadStatus: $downloadStatus)'; +} diff --git a/lib/src/state_management/quran/recite/quran_audio_player_notifier.dart b/lib/src/state_management/quran/recite/quran_audio_player_notifier.dart index 1a750cd0..85292d8f 100644 --- a/lib/src/state_management/quran/recite/quran_audio_player_notifier.dart +++ b/lib/src/state_management/quran/recite/quran_audio_player_notifier.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:developer'; -import 'dart:math' show Random; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; @@ -8,9 +7,16 @@ import 'package:mawaqit/src/domain/model/quran/moshaf_model.dart'; import 'package:mawaqit/src/domain/model/quran/surah_model.dart'; import 'package:mawaqit/src/state_management/quran/recite/quran_audio_player_state.dart'; +import '../../../data/repository/quran/recite_impl.dart'; +import '../../../domain/model/quran/audio_file_model.dart'; +import '../../../helpers/connectivity_provider.dart'; +import '../../../models/address_model.dart'; +import 'download_audio_quran/download_audio_quran_notifier.dart'; +import 'download_audio_quran/download_audio_quran_state.dart'; + class QuranAudioPlayer extends AsyncNotifier { final AudioPlayer audioPlayer = AudioPlayer(); - late ConcatenatingAudioSource playlist; + ConcatenatingAudioSource playlist = ConcatenatingAudioSource(children: []); int index = 0; List localSuwar = []; late StreamSubscription currentIndexSubscription; @@ -33,41 +39,219 @@ class QuranAudioPlayer extends AsyncNotifier { ); } + Future downloadAudio({ + required String reciterId, + required String moshafId, + required int surahId, + required String url, + }) async { + final audioRepository = await ref.read(reciteImplProvider.future); + final audioFileModel = AudioFileModel( + reciterId, + moshafId, + surahId.toString(), + url, + ); + + final downloadStateNotifier = ref.read( + downloadStateProvider( + DownloadStateProviderParameter(reciterId: reciterId, moshafId: moshafId), + ).notifier, + ); + + downloadStateNotifier.updateDownloadProgress(surahId, 0); + + try { + await audioRepository.downloadAudio(audioFileModel, (progress) { + downloadStateNotifier.updateDownloadProgress(surahId, progress / 100); + }); + + downloadStateNotifier.markAsDownloaded(surahId); + + await getDownloadedSuwarByReciterAndRiwayah( + moshafId: moshafId, + reciterId: reciterId, + ); + + await refreshPlaylist(); + } catch (e) { + state = AsyncError(e, StackTrace.current); + } + } + + Future refreshPlaylist() async { + final currentIndex = audioPlayer.currentIndex; + await audioPlayer.setAudioSource(playlist, initialIndex: currentIndex); + await _updatePlayerState(); + } + + Future _updatePlayerState() async { + final index = audioPlayer.currentIndex ?? 0; + final position = audioPlayer.position; + + if (index < localSuwar.length) { + state = AsyncData( + state.value!.copyWith( + surahName: localSuwar[index].name, + playerState: audioPlayer.playing ? AudioPlayerState.playing : AudioPlayerState.paused, + position: position, + ), + ); + } + } + + Future _downloadAllSuwar( + List suwar, + String reciterId, + String moshafId, + String server, + ) async { + final downloadStateNotifier = ref.read( + downloadStateProvider( + DownloadStateProviderParameter(reciterId: reciterId, moshafId: moshafId), + ).notifier, + ); + + int downloadedCount = 0; + for (final surah in suwar) { + final downloadState = ref.read( + downloadStateProvider( + DownloadStateProviderParameter(reciterId: reciterId, moshafId: moshafId), + ), + ); + if (!downloadState.downloadedSuwar.contains(surah.id)) { + downloadStateNotifier.setDownloadStatus(DownloadStatus.downloading); + await downloadAudio( + reciterId: reciterId, + moshafId: moshafId, + surahId: surah.id, + url: surah.getSurahUrl(server), + ); + downloadedCount++; + } + } + return downloadedCount; + } + + Future downloadAllSuwar({ + required String reciterId, + required String moshafId, + required MoshafModel moshaf, + required List suwar, + }) async { + try { + state = AsyncLoading(); + final downloadedCount = await _downloadAllSuwar( + suwar, + reciterId, + moshafId, + moshaf.server, + ); + state = AsyncData(state.value!); + + final downloadStateNotifier = ref.read( + downloadStateProvider( + DownloadStateProviderParameter(reciterId: reciterId, moshafId: moshafId), + ).notifier, + ); + + if (downloadedCount > 0) { + downloadStateNotifier.setDownloadStatus(DownloadStatus.completed); + } else { + downloadStateNotifier.setDownloadStatus(DownloadStatus.noNewDownloads); + } + } catch (e, s) { + state = AsyncError(e, s); + } + } + + Future getDownloadedSuwarByReciterAndRiwayah({ + required String reciterId, + required String moshafId, + }) async { + final audioRepository = await ref.read(reciteImplProvider.future); + state = AsyncLoading(); + try { + final downloadedAudioList = await audioRepository.getDownloadedSuwarByReciterAndRiwayah( + reciterId: reciterId, + moshafId: moshafId, + ); + + final downloadedSurahIds = + downloadedAudioList.map((file) => int.parse(file.path.split('/').last.split('.').first)).toSet(); + + final downloadStateNotifier = ref.read(downloadStateProvider( + DownloadStateProviderParameter(reciterId: reciterId, moshafId: moshafId), + ).notifier); + + downloadStateNotifier.initializeDownloadedSuwar(downloadedSurahIds); + } catch (e, s) { + state = AsyncError(e, s); + } + } + void initialize({ required MoshafModel moshaf, required SurahModel surah, required List suwar, + required String reciterId, }) async { - log('quran: QuranAudioPlayer: play audio: ${surah.name}, url ${surah.getSurahUrl(moshaf.server)}'); - - playlist = ConcatenatingAudioSource( - shuffleOrder: DefaultShuffleOrder(), - children: suwar - .map( - (e) => AudioSource.uri( - Uri.parse( - e.getSurahUrl(moshaf.server), - ), - ), - ) - .toList(), - ); - index = suwar.indexOf(surah); - localSuwar = suwar; - await audioPlayer.setAudioSource(playlist, initialIndex: index); - log('quran: QuranAudioPlayer: initialized ${surah.name}, url ${surah.getSurahUrl(moshaf.server)}'); - currentIndexSubscription = audioPlayer.currentIndexStream.listen((index) { - log('quran: QuranAudioPlayer: currentIndexStream called'); - final index = audioPlayer.currentIndex ?? 0; - final currentPlayerState = audioPlayer.playing ? AudioPlayerState.playing : AudioPlayerState.paused; + try { + final audioRepository = await ref.read(reciteImplProvider.future); + List audioSources = []; + localSuwar = []; + + for (var s in suwar) { + bool isDownloaded = await audioRepository.isSurahDownloaded( + reciterId: reciterId, + moshafId: moshaf.id.toString(), + surahNumber: s.id, + ); + + if (isDownloaded) { + String localPath = await audioRepository.getLocalSurahPath( + reciterId: reciterId, + surahNumber: s.id.toString(), + moshafId: moshaf.id.toString(), + ); + audioSources.add(AudioSource.uri(Uri.file(localPath))); + localSuwar.add(s); + log('quran: QuranAudioPlayer: isDownloaded: ${s.name}, path: ${localPath}'); + } else if (ref.read(connectivityProvider).hasValue && + ref.read(connectivityProvider).value == ConnectivityStatus.connected) { + audioSources.add(AudioSource.uri(Uri.parse(s.getSurahUrl(moshaf.server)))); + localSuwar.add(s); + log('quran: QuranAudioPlayer: isOnline: ${s.name}, url: ${s.getSurahUrl(moshaf.server)}'); + } + } + + if (audioSources.isEmpty) { + throw Exception('No audio sources available'); + } + + playlist.clear(); + playlist.addAll(audioSources); + index = localSuwar.indexOf(surah); + await audioPlayer.setAudioSource(playlist, initialIndex: index); + + currentIndexSubscription = audioPlayer.currentIndexStream.listen((index) { + log('quran: QuranAudioPlayer: currentIndexStream called'); + _updatePlayerState(); + }); + + audioPlayer.playerStateStream.listen((playerState) { + _updatePlayerState(); + }); + state = AsyncData( state.value!.copyWith( - surahName: localSuwar[index].name, - playerState: currentPlayerState, + surahName: surah.name, reciterName: moshaf.name, ), ); - }); + } catch (e, s) { + state = AsyncError(e, s); + } } Future play() async { diff --git a/pubspec.yaml b/pubspec.yaml index 54d700ca..f424a7e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -136,6 +136,7 @@ dependencies: wakelock_plus: 1.1.4 wakelock_plus_platform_interface: 1.1.0 + percent_indicator: ^4.2.3 # functional programming fpdart: ^1.1.0