diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e16f58e..df7e1c0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -32,6 +32,37 @@ "message": 4 } } + }, + // flutter build apk --release + { + "icon": { "id": "package", "color": "terminal.ansiGreen" }, + "label": "flutter build release APK", + "type": "shell", + "command": "flutter build apk --release", + "group": { + "kind": "none", + "isDefault": false + }, + "detail": "Building APK in release mode", + "presentation": { + "revealProblems": "onProblem", + "reveal": "always", + "panel": "dedicated" + }, + "runOptions": { + "instanceLimit": 1 + }, + "problemMatcher": { + "owner": "dart", + "fileLocation": ["relative", "${workspaceFolder}"], + "pattern": { + "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + } } ] } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cd911ca..62d44d0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + .internal( ); typedef MeRef = AutoDisposeFutureProviderRef; -String _$personalizedViewHash() => r'2e70fe2bfc766a963f7a8e94211ad50d959fbaa2'; +String _$personalizedViewHash() => r'dada8d72845ffd516f731f88193941f7ebdd47ed'; /// fetch the personalized view /// diff --git a/lib/api/authenticated_user_provider.g.dart b/lib/api/authenticated_user_provider.g.dart index 1ede072..cd41f52 100644 --- a/lib/api/authenticated_user_provider.g.dart +++ b/lib/api/authenticated_user_provider.g.dart @@ -6,7 +6,7 @@ part of 'authenticated_user_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$authenticatedUserHash() => r'5702fb6ab1e83129d57c89ef02a65c5910f2a076'; +String _$authenticatedUserHash() => r'8578d7fda1755ecacce6853076da4149e4ebe3e7'; /// provides with a set of authenticated users /// diff --git a/lib/api/image_provider.g.dart b/lib/api/image_provider.g.dart index 87240f0..74f9d8e 100644 --- a/lib/api/image_provider.g.dart +++ b/lib/api/image_provider.g.dart @@ -6,7 +6,7 @@ part of 'image_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$coverImageHash() => r'fa97592576b5450053066fcd644f2b5c30d3a5bc'; +String _$coverImageHash() => r'702afafa217dfcbb290837caf21cc1ef54defd55'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/api/library_item_provider.g.dart b/lib/api/library_item_provider.g.dart index 68b1586..80370c6 100644 --- a/lib/api/library_item_provider.g.dart +++ b/lib/api/library_item_provider.g.dart @@ -6,7 +6,7 @@ part of 'library_item_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$libraryItemHash() => r'4c9a9e6d6700c7c76fbf56ecf5c0873155d5061a'; +String _$libraryItemHash() => r'fa3f8309349c5b1b777f1bc919616e51c3f5b520'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/features/downloads/core/download_manager.dart b/lib/features/downloads/core/download_manager.dart new file mode 100644 index 0000000..5caba14 --- /dev/null +++ b/lib/features/downloads/core/download_manager.dart @@ -0,0 +1,185 @@ +// download manager to handle download tasks of files + +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:whispering_pages/shared/extensions/model_conversions.dart'; + +final _logger = Logger('AudiobookDownloadManager'); +final tq = MemoryTaskQueue(); +const downloadDirectory = BaseDirectory.applicationSupport; + +class AudiobookDownloadManager { + // takes in the baseUrl and the token + AudiobookDownloadManager({ + required this.baseUrl, + required this.token, + this.requiresWiFi = true, + this.retries = 0, + this.allowPause = false, + + // /// The maximum number of concurrent tasks to run at any given time. + // int maxConcurrent = 3, + + // /// The maximum number of concurrent tasks to run for the same host. + // int maxConcurrentByHost = 2, + + // /// The maximum number of concurrent tasks to run for the same group. + // int maxConcurrentByGroup = 3, + }) { + // initialize the download manager + + FileDownloader().addTaskQueue(tq); + + _logger.fine('initialized with baseUrl: $baseUrl, token: $token'); + _logger.fine( + 'requiresWiFi: $requiresWiFi, retries: $retries, allowPause: $allowPause', + ); + } + + // the base url for the audio files + final String baseUrl; + + // the authentication token to access the [AudioTrack.contentUrl] + final String token; + + // whether to download only on wifi + final bool requiresWiFi; + + // the number of retries to attempt + final int retries; + + // whether to allow pausing of downloads + final bool allowPause; + + // final List _downloadTasks = []; + + Future queueAudioBookDownload(LibraryItemExpanded item) async { + _logger.info('queuing download for item: ${item.toJson()}'); + // create a download task for each file in the item + final directory = await getApplicationSupportDirectory(); + for (final file in item.libraryFiles) { + // check if the file is already downloaded + if (isFileDownloaded( + constructFilePath(directory, item, file), + )) { + _logger.info('file already downloaded: ${file.metadata.filename}'); + continue; + } + + final task = DownloadTask( + taskId: file.ino, + url: file.url(baseUrl, item.id, token).toString(), + directory: item.relPath, + filename: file.metadata.filename, + requiresWiFi: requiresWiFi, + retries: retries, + allowPause: allowPause, + group: item.id, + baseDirectory: downloadDirectory, + // metaData: token + ); + // _downloadTasks.add(task); + tq.add(task); + _logger.info('queued task: ${task.toJson()}'); + } + + FileDownloader().registerCallbacks( + group: item.id, + taskProgressCallback: (update) { + _logger.info('Group: ${item.id}, Progress Update: ${update.progress}'); + }, + taskStatusCallback: (update) { + switch (update.status) { + case TaskStatus.complete: + _logger.info('Group: ${item.id}, Download Complete'); + break; + case TaskStatus.failed: + _logger.warning('Group: ${item.id}, Download Failed'); + break; + default: + _logger + .info('Group: ${item.id}, Download Status: ${update.status}'); + } + }, + taskNotificationTapCallback: (task, notificationType) { + _logger.info('Group: ${item.id}, Task: ${task.toJson()}'); + }, + ); + } + + String constructFilePath( + Directory directory, + LibraryItemExpanded item, + LibraryFile file, + ) => + '${directory.path}/${item.relPath}/${file.metadata.filename}'; + + // void startDownload() { + // for (final task in _downloadTasks) { + // _logger.fine('enqueuing task: $task'); + // FileDownloader().enqueue(task); + // } + // } + + void dispose() { + // tq.removeAll(); + _logger.fine('disposed'); + } + + bool isFileDownloaded(String filePath) { + // Check if the file exists + final fileExists = File(filePath).existsSync(); + + return fileExists; + } + + Future isItemDownloaded(LibraryItemExpanded item) async { + final directory = await getApplicationSupportDirectory(); + for (final file in item.libraryFiles) { + if (!isFileDownloaded(constructFilePath(directory, item, file))) { + _logger.info('file not downloaded: ${file.metadata.filename}'); + return false; + } + } + + return true; + } + + Future deleteDownloadedItem(LibraryItemExpanded item) async { + final directory = await getApplicationSupportDirectory(); + for (final file in item.libraryFiles) { + final filePath = constructFilePath(directory, item, file); + if (isFileDownloaded(filePath)) { + File(filePath).deleteSync(); + } + _logger.info('deleted file: $filePath'); + } + } + + Future> getDownloadedFiles(LibraryItemExpanded item) async { + final directory = await getApplicationSupportDirectory(); + final files = []; + for (final file in item.libraryFiles) { + final filePath = constructFilePath(directory, item, file); + if (isFileDownloaded(filePath)) { + files.add(Uri.file(filePath)); + } + } + + return files; + } +} + +Future initDownloadManager() async { + // initialize the download manager + var fileDownloader = FileDownloader(); + fileDownloader.configureNotification( + running: const TaskNotification('Downloading', 'file: {filename}'), + progressBar: true, + ); + await fileDownloader.trackTasks(); +} diff --git a/lib/features/downloads/providers/download_manager.dart b/lib/features/downloads/providers/download_manager.dart new file mode 100644 index 0000000..5db75c7 --- /dev/null +++ b/lib/features/downloads/providers/download_manager.dart @@ -0,0 +1,51 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:whispering_pages/api/api_provider.dart'; +import 'package:whispering_pages/features/downloads/core/download_manager.dart' + as core; +import 'package:whispering_pages/settings/app_settings_provider.dart'; + +part 'download_manager.g.dart'; + +@Riverpod(keepAlive: true) +class SimpleDownloadManager extends _$SimpleDownloadManager { + @override + core.AudiobookDownloadManager build() { + final api = ref.watch(authenticatedApiProvider); + final downloadSettings = ref.watch(appSettingsProvider).downloadSettings; + + final manager = core.AudiobookDownloadManager( + baseUrl: api.baseUrl.toString(), + token: api.token!, + requiresWiFi: downloadSettings.requiresWiFi, + retries: downloadSettings.retries, + allowPause: downloadSettings.allowPause, + ); + core.tq.maxConcurrent = downloadSettings.maxConcurrent; + core.tq.maxConcurrentByHost = downloadSettings.maxConcurrentByHost; + core.tq.maxConcurrentByGroup = downloadSettings.maxConcurrentByGroup; + ref.onDispose(() { + manager.dispose(); + }); + + return manager; + } +} + +@riverpod +FutureOr> downloadHistory( + DownloadHistoryRef ref, { + String? group, +}) async { + return await FileDownloader().database.allRecords(group: group); +} + +@Riverpod(keepAlive: false) +FutureOr downloadStatus( + DownloadStatusRef ref, + LibraryItemExpanded item, +) async { + final manager = ref.read(simpleDownloadManagerProvider); + return manager.isItemDownloaded(item); +} diff --git a/lib/features/downloads/providers/download_manager.g.dart b/lib/features/downloads/providers/download_manager.g.dart new file mode 100644 index 0000000..7a21240 --- /dev/null +++ b/lib/features/downloads/providers/download_manager.g.dart @@ -0,0 +1,306 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'download_manager.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$downloadHistoryHash() => r'76c449e8abfa61d57566991686f534a06dc7fef7'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [downloadHistory]. +@ProviderFor(downloadHistory) +const downloadHistoryProvider = DownloadHistoryFamily(); + +/// See also [downloadHistory]. +class DownloadHistoryFamily extends Family>> { + /// See also [downloadHistory]. + const DownloadHistoryFamily(); + + /// See also [downloadHistory]. + DownloadHistoryProvider call({ + String? group, + }) { + return DownloadHistoryProvider( + group: group, + ); + } + + @override + DownloadHistoryProvider getProviderOverride( + covariant DownloadHistoryProvider provider, + ) { + return call( + group: provider.group, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'downloadHistoryProvider'; +} + +/// See also [downloadHistory]. +class DownloadHistoryProvider + extends AutoDisposeFutureProvider> { + /// See also [downloadHistory]. + DownloadHistoryProvider({ + String? group, + }) : this._internal( + (ref) => downloadHistory( + ref as DownloadHistoryRef, + group: group, + ), + from: downloadHistoryProvider, + name: r'downloadHistoryProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$downloadHistoryHash, + dependencies: DownloadHistoryFamily._dependencies, + allTransitiveDependencies: + DownloadHistoryFamily._allTransitiveDependencies, + group: group, + ); + + DownloadHistoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.group, + }) : super.internal(); + + final String? group; + + @override + Override overrideWith( + FutureOr> Function(DownloadHistoryRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: DownloadHistoryProvider._internal( + (ref) => create(ref as DownloadHistoryRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + group: group, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _DownloadHistoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is DownloadHistoryProvider && other.group == group; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, group.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin DownloadHistoryRef on AutoDisposeFutureProviderRef> { + /// The parameter `group` of this provider. + String? get group; +} + +class _DownloadHistoryProviderElement + extends AutoDisposeFutureProviderElement> + with DownloadHistoryRef { + _DownloadHistoryProviderElement(super.provider); + + @override + String? get group => (origin as DownloadHistoryProvider).group; +} + +String _$downloadStatusHash() => r'f37b4678d3c2a7c6e985b0149d72ea0f9b1b42ca'; + +/// See also [downloadStatus]. +@ProviderFor(downloadStatus) +const downloadStatusProvider = DownloadStatusFamily(); + +/// See also [downloadStatus]. +class DownloadStatusFamily extends Family> { + /// See also [downloadStatus]. + const DownloadStatusFamily(); + + /// See also [downloadStatus]. + DownloadStatusProvider call( + LibraryItemExpanded item, + ) { + return DownloadStatusProvider( + item, + ); + } + + @override + DownloadStatusProvider getProviderOverride( + covariant DownloadStatusProvider provider, + ) { + return call( + provider.item, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'downloadStatusProvider'; +} + +/// See also [downloadStatus]. +class DownloadStatusProvider extends AutoDisposeFutureProvider { + /// See also [downloadStatus]. + DownloadStatusProvider( + LibraryItemExpanded item, + ) : this._internal( + (ref) => downloadStatus( + ref as DownloadStatusRef, + item, + ), + from: downloadStatusProvider, + name: r'downloadStatusProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$downloadStatusHash, + dependencies: DownloadStatusFamily._dependencies, + allTransitiveDependencies: + DownloadStatusFamily._allTransitiveDependencies, + item: item, + ); + + DownloadStatusProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.item, + }) : super.internal(); + + final LibraryItemExpanded item; + + @override + Override overrideWith( + FutureOr Function(DownloadStatusRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: DownloadStatusProvider._internal( + (ref) => create(ref as DownloadStatusRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + item: item, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _DownloadStatusProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is DownloadStatusProvider && other.item == item; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, item.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin DownloadStatusRef on AutoDisposeFutureProviderRef { + /// The parameter `item` of this provider. + LibraryItemExpanded get item; +} + +class _DownloadStatusProviderElement + extends AutoDisposeFutureProviderElement with DownloadStatusRef { + _DownloadStatusProviderElement(super.provider); + + @override + LibraryItemExpanded get item => (origin as DownloadStatusProvider).item; +} + +String _$simpleDownloadManagerHash() => + r'cec95717c86e422f88f78aa014d29e800e5a2089'; + +/// See also [SimpleDownloadManager]. +@ProviderFor(SimpleDownloadManager) +final simpleDownloadManagerProvider = NotifierProvider.internal( + SimpleDownloadManager.new, + name: r'simpleDownloadManagerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$simpleDownloadManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SimpleDownloadManager = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/downloads/view/downloads_page.dart b/lib/features/downloads/view/downloads_page.dart new file mode 100644 index 0000000..9a68186 --- /dev/null +++ b/lib/features/downloads/view/downloads_page.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/features/downloads/providers/download_manager.dart'; + +class DownloadsPage extends HookConsumerWidget { + const DownloadsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final manager = ref.read(simpleDownloadManagerProvider); + final downloadHistory = ref.watch(downloadHistoryProvider()); + + return Scaffold( + appBar: AppBar( + title: const Text('Downloads'), + backgroundColor: Colors.transparent, + ), + body: Center( + // history of downloads + child: downloadHistory.when( + data: (records) { + // each group is one list tile, which contains the files downloaded + final uniqueGroups = records.map((e) => e.group).toSet(); + return ListView.builder( + itemCount: uniqueGroups.length, + itemBuilder: (context, index) { + final group = uniqueGroups.elementAt(index); + final groupRecords = records.where((e) => e.group == group); + return ExpansionTile( + title: Text(group ?? 'No Group'), + children: groupRecords + .map( + (e) => ListTile( + title: Text('${e.task.directory}/${e.task.filename}'), + subtitle: Text(e.task.creationTime.toString()), + ), + ) + .toList(), + ); + }, + ); + }, + loading: () => const CircularProgressIndicator(), + error: (error, stackTrace) { + return Text('Error: $error'); + }, + ), + ), + ); + } +} diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart index 61b52a0..f9a25fc 100644 --- a/lib/features/item_viewer/view/library_item_actions.dart +++ b/lib/features/item_viewer/view/library_item_actions.dart @@ -1,9 +1,18 @@ +import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; +import 'package:whispering_pages/api/library_item_provider.dart'; import 'package:whispering_pages/constants/hero_tag_conventions.dart'; +import 'package:whispering_pages/features/downloads/providers/download_manager.dart' + show + downloadHistoryProvider, + downloadStatusProvider, + simpleDownloadManagerProvider; import 'package:whispering_pages/features/item_viewer/view/library_item_page.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/main.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/model_conversions.dart'; @@ -19,7 +28,10 @@ class LibraryItemActions extends HookConsumerWidget { late final shelfsdk.BookExpanded book; @override Widget build(BuildContext context, WidgetRef ref) { - final player = ref.read(audiobookPlayerProvider); + final manager = ref.read(simpleDownloadManagerProvider); + final downloadHistory = ref.watch(downloadHistoryProvider(group: item.id)); + final isItemDownloaded = ref.watch(downloadStatusProvider(item)); + return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), child: Row( @@ -55,16 +67,150 @@ class LibraryItemActions extends HookConsumerWidget { onPressed: () {}, icon: const Icon(Icons.share_rounded), ), - // download button - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.download_rounded, - ), + // check if the book is downloaded using manager.isItemDownloaded + isItemDownloaded.when( + data: (isDownloaded) { + if (isDownloaded) { + // already downloaded button + return IconButton( + onPressed: () { + appLogger + .fine('Pressed already downloaded button'); + // manager.openDownloadedFile(item); + // open popup menu to open or delete the file + showModalBottomSheet( + // useRootNavigator: true, + context: context, + builder: (context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: DownloadSheet( + item: item, + ), + ); + }, + ); + }, + icon: const Icon( + Icons.download_done_rounded, + ), + ); + } + // download button + return IconButton( + onPressed: () { + appLogger.fine('Pressed download button'); + manager.queueAudioBookDownload(item); + }, + icon: const Icon( + Icons.download_rounded, + ), + ); + }, + loading: () => const CircularProgressIndicator(), + error: (error, stackTrace) { + return IconButton( + onPressed: () { + appLogger.warning( + 'Error checking download status: $error', + ); + }, + icon: const Icon(Icons.error_rounded), + ); + }, ), // more button IconButton( - onPressed: () {}, + onPressed: () { + // show the bottom sheet with download history + showModalBottomSheet( + context: context, + builder: (context) { + return downloadHistory.when( + data: (downloadHistory) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: ListView.builder( + itemCount: downloadHistory.length, + itemBuilder: (context, index) { + final record = downloadHistory[index]; + return ListTile( + title: Text(record.task.filename), + subtitle: Text( + '${record.task.directory}/${record.task.baseDirectory}', + ), + trailing: const Icon( + Icons.open_in_new_rounded, + ), + onLongPress: () { + // show the delete dialog + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete'), + content: Text( + 'Are you sure you want to delete ${record.task.filename}?', + ), + actions: [ + TextButton( + onPressed: () { + // delete the file + FileDownloader() + .database + .deleteRecordWithId( + record + .task.taskId, + ); + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('No'), + ), + ], + ); + }, + ); + }, + onTap: () async { + // open the file location + final didOpen = + await FileDownloader().openFile( + task: record.task, + ); + + if (!didOpen) { + appLogger.warning( + 'Failed to open file: ${record.task.filename} at ${record.task.directory}', + ); + return; + } + appLogger.fine( + 'Opened file: ${record.task.filename} at ${record.task.directory}', + ); + }, + ); + }, + ), + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Center( + child: Text('Error: $error'), + ), + ); + }, + ); + }, icon: const Icon( Icons.more_vert_rounded, ), @@ -81,6 +227,84 @@ class LibraryItemActions extends HookConsumerWidget { } } +class DownloadSheet extends HookConsumerWidget { + const DownloadSheet({ + super.key, + required this.item, + }); + + final shelfsdk.LibraryItemExpanded item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final manager = ref.read(simpleDownloadManagerProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ListTile( + // title: const Text('Open'), + // onTap: () async { + // final didOpen = + // await FileDownloader().openFile( + // task: manager.getTaskForItem(item), + // ); + + // if (!didOpen) { + // appLogger.warning( + // 'Failed to open file: ${item.title}', + // ); + // return; + // } + // appLogger.fine( + // 'Opened file: ${item.title}', + // ); + // }, + // ), + ListTile( + title: const Text('Delete'), + leading: const Icon( + Icons.delete_rounded, + ), + onTap: () { + // show the delete dialog + showDialog( + useRootNavigator: false, + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete'), + content: Text( + 'Are you sure you want to delete ${item.media.metadata.title}?', + ), + actions: [ + TextButton( + onPressed: () { + // delete the file + manager.deleteDownloadedItem( + item, + ); + GoRouter.of(context).pop(); + }, + child: const Text('Yes'), + ), + TextButton( + onPressed: () { + GoRouter.of(context).pop(); + }, + child: const Text('No'), + ), + ], + ); + }, + ); + }, + ), + ], + ); + } +} + class _LibraryItemPlayButton extends HookConsumerWidget { const _LibraryItemPlayButton({ super.key, @@ -122,7 +346,10 @@ class _LibraryItemPlayButton extends HookConsumerWidget { return ElevatedButton.icon( onPressed: () => libraryItemPlayButtonOnPressed( - ref: ref, book: book, userMediaProgress: userMediaProgress), + ref: ref, + book: book, + userMediaProgress: userMediaProgress, + ), icon: Hero( tag: HeroTagPrefixes.libraryItemPlayButton + book.libraryItemId, child: DynamicItemPlayIcon( @@ -182,9 +409,14 @@ Future libraryItemPlayButtonOnPressed({ if (!isCurrentBookSetInPlayer) { debugPrint('Setting the book ${book.libraryItemId}'); debugPrint('Initial position: ${userMediaProgress?.currentTime}'); - await player.setSourceAudioBook( + final downloadManager = ref.watch(simpleDownloadManagerProvider); + final libItem = + await ref.read(libraryItemProvider(book.libraryItemId).future); + final downloadedUris = await downloadManager.getDownloadedFiles(libItem); + await player.setSourceAudiobook( book, initialPosition: userMediaProgress?.currentTime, + downloadedUris: downloadedUris, ); } else { debugPrint('Book was already set'); diff --git a/lib/features/item_viewer/view/library_item_page.dart b/lib/features/item_viewer/view/library_item_page.dart index a3e40de..44f688f 100644 --- a/lib/features/item_viewer/view/library_item_page.dart +++ b/lib/features/item_viewer/view/library_item_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:whispering_pages/api/library_item_provider.dart'; import 'package:whispering_pages/features/item_viewer/view/library_item_sliver_app_bar.dart'; +import 'package:whispering_pages/features/player/providers/player_form.dart'; import 'package:whispering_pages/router/models/library_item_extras.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/model_conversions.dart'; @@ -101,6 +102,10 @@ class LibraryItemPage extends HookConsumerWidget { ) : const SizedBox.shrink(), ), + // a padding at the bottom to make sure the last item is not hidden by mini player + const SliverToBoxAdapter( + child: SizedBox(height: playerMinHeight), + ), ], ), ), diff --git a/lib/features/library_browser/view/library_browser_page.dart b/lib/features/library_browser/view/library_browser_page.dart new file mode 100644 index 0000000..40d46cf --- /dev/null +++ b/lib/features/library_browser/view/library_browser_page.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/router/router.dart'; + +class LibraryBrowserPage extends HookConsumerWidget { + const LibraryBrowserPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text('Library'), + backgroundColor: Colors.transparent, + ), + // a list redirecting to authors, genres, and series pages + body: ListView( + children: [ + ListTile( + title: const Text('Authors'), + leading: const Icon(Icons.person), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ), + ListTile( + title: const Text('Genres'), + leading: const Icon(Icons.category), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ), + ListTile( + title: const Text('Series'), + leading: const Icon(Icons.list), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ), + // Downloads + ListTile( + title: const Text('Downloads'), + leading: const Icon(Icons.download), + trailing: const Icon(Icons.chevron_right), + onTap: () { + GoRouter.of(context).pushNamed(Routes.downloads.name); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/player/core/audiobook_player.dart b/lib/features/player/core/audiobook_player.dart index cc6c471..106a547 100644 --- a/lib/features/player/core/audiobook_player.dart +++ b/lib/features/player/core/audiobook_player.dart @@ -3,11 +3,14 @@ /// this is needed as audiobook can be a list of audio files instead of a single file library; -import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; +import 'package:logging/logging.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; +final _logger = Logger('AudiobookPlayer'); + /// returns the sum of the duration of all the previous tracks before the [index] Duration sumOfTracks(BookExpanded book, int? index) { // return 0 if index is less than 0 @@ -54,7 +57,7 @@ class AudiobookPlayer extends AudioPlayer { /// the [BookExpanded] being played /// - /// to set the book, use [setSourceAudioBook] + /// to set the book, use [setSourceAudiobook] BookExpanded? get book => _book; /// the authentication token to access the [AudioTrack.contentUrl] @@ -70,21 +73,24 @@ class AudiobookPlayer extends AudioPlayer { int? get availableTracks => _book?.tracks.length; /// sets the current [AudioTrack] as the source of the player - Future setSourceAudioBook( + Future setSourceAudiobook( BookExpanded? book, { bool preload = true, // int? initialIndex, Duration? initialPosition, + List? downloadedUris, + Uri? artworkUri, }) async { // if the book is null, stop the player if (book == null) { _book = null; + _logger.info('Book is null, stopping player'); return stop(); } // see if the book is the same as the current book if (_book == book) { - // if the book is the same, do nothing + _logger.info('Book is the same, doing nothing'); return; } // first stop the player and clear the source @@ -111,23 +117,29 @@ class AudiobookPlayer extends AudioPlayer { ConcatenatingAudioSource( useLazyPreparation: true, children: book.tracks.map((track) { + final retrievedUri = + _getUri(track, downloadedUris, baseUrl: baseUrl, token: token); + _logger.fine( + 'Setting source for track: ${track.title}, URI: $retrievedUri', + ); return AudioSource.uri( - Uri.parse('$baseUrl${track.contentUrl}?token=$token'), + retrievedUri, tag: MediaItem( // Specify a unique ID for each media item: id: book.libraryItemId + track.index.toString(), // Metadata to display in the notification: album: book.metadata.title, title: book.metadata.title ?? track.title, - artUri: Uri.parse( - '$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800', - ), + artUri: artworkUri ?? + Uri.parse( + '$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800', + ), ), ); }).toList(), ), ).catchError((error) { - debugPrint('AudiobookPlayer Error: $error'); + _logger.shout('Error: $error'); }); } @@ -176,7 +188,8 @@ class AudiobookPlayer extends AudioPlayer { if (_book == null) { return Duration.zero; } - return bufferedPosition + _book!.tracks[sequenceState!.currentIndex].startOffset; + return bufferedPosition + + _book!.tracks[sequenceState!.currentIndex].startOffset; } /// streams to override to suit the book instead of the current track @@ -237,3 +250,20 @@ class AudiobookPlayer extends AudioPlayer { ); } } + +Uri _getUri( + AudioTrack track, + List? downloadedUris, { + required Uri baseUrl, + required String token, +}) { + // check if the track is in the downloadedUris + final uri = downloadedUris?.firstWhereOrNull( + (element) { + return element.pathSegments.last == track.metadata?.filename; + }, + ); + + return uri ?? + Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token'); +} diff --git a/lib/features/player/providers/audiobook_player.dart b/lib/features/player/providers/audiobook_player.dart index 593854b..ede5013 100644 --- a/lib/features/player/providers/audiobook_player.dart +++ b/lib/features/player/providers/audiobook_player.dart @@ -1,16 +1,16 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:whispering_pages/api/api_provider.dart'; import 'package:whispering_pages/features/player/core/audiobook_player.dart' - as abp; + as core; part 'audiobook_player.g.dart'; // @Riverpod(keepAlive: true) -// abp.AudiobookPlayer audiobookPlayer( +// core.AudiobookPlayer audiobookPlayer( // AudiobookPlayerRef ref, // ) { // final api = ref.watch(authenticatedApiProvider); -// final player = abp.AudiobookPlayer(api.token!, api.baseUrl); +// final player = core.AudiobookPlayer(api.token!, api.baseUrl); // ref.onDispose(player.dispose); @@ -24,9 +24,12 @@ const playerId = 'audiobook_player'; @Riverpod(keepAlive: true) class SimpleAudiobookPlayer extends _$SimpleAudiobookPlayer { @override - abp.AudiobookPlayer build() { + core.AudiobookPlayer build() { final api = ref.watch(authenticatedApiProvider); - final player = abp.AudiobookPlayer(api.token!, api.baseUrl); + final player = core.AudiobookPlayer( + api.token!, + api.baseUrl, + ); ref.onDispose(player.dispose); @@ -37,7 +40,7 @@ class SimpleAudiobookPlayer extends _$SimpleAudiobookPlayer { @Riverpod(keepAlive: true) class AudiobookPlayer extends _$AudiobookPlayer { @override - abp.AudiobookPlayer build() { + core.AudiobookPlayer build() { final player = ref.watch(simpleAudiobookPlayerProvider); ref.onDispose(player.dispose); diff --git a/lib/features/player/providers/audiobook_player.g.dart b/lib/features/player/providers/audiobook_player.g.dart index 1ff198c..849cde6 100644 --- a/lib/features/player/providers/audiobook_player.g.dart +++ b/lib/features/player/providers/audiobook_player.g.dart @@ -7,7 +7,7 @@ part of 'audiobook_player.dart'; // ************************************************************************** String _$simpleAudiobookPlayerHash() => - r'b65e6d779476a2c1fa38f617771bf997acb4f5b8'; + r'9e11ed2791d35e308f8cbe61a79a45cf51466ebb'; /// Simple because it doesn't rebuild when the player state changes /// it only rebuilds when the token changes @@ -15,7 +15,7 @@ String _$simpleAudiobookPlayerHash() => /// Copied from [SimpleAudiobookPlayer]. @ProviderFor(SimpleAudiobookPlayer) final simpleAudiobookPlayerProvider = - NotifierProvider.internal( + NotifierProvider.internal( SimpleAudiobookPlayer.new, name: r'simpleAudiobookPlayerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -25,13 +25,13 @@ final simpleAudiobookPlayerProvider = allTransitiveDependencies: null, ); -typedef _$SimpleAudiobookPlayer = Notifier; -String _$audiobookPlayerHash() => r'38042d0c93034e6907677fdb614a9af1b9d636af'; +typedef _$SimpleAudiobookPlayer = Notifier; +String _$audiobookPlayerHash() => r'44394b1dbbf85eb19ef1f693717e8cbc15b768e5'; /// See also [AudiobookPlayer]. @ProviderFor(AudiobookPlayer) final audiobookPlayerProvider = - NotifierProvider.internal( + NotifierProvider.internal( AudiobookPlayer.new, name: r'audiobookPlayerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -41,6 +41,6 @@ final audiobookPlayerProvider = allTransitiveDependencies: null, ); -typedef _$AudiobookPlayer = Notifier; +typedef _$AudiobookPlayer = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/player/providers/player_form.dart b/lib/features/player/providers/player_form.dart index 1b0c3f2..975f47f 100644 --- a/lib/features/player/providers/player_form.dart +++ b/lib/features/player/providers/player_form.dart @@ -9,6 +9,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'player_form.g.dart'; +/// The height of the player when it is minimized const double playerMinHeight = 70; // const miniplayerPercentageDeclaration = 0.2; diff --git a/lib/features/player/view/audiobook_player.dart b/lib/features/player/view/audiobook_player.dart index cfb6b5e..aa9e33f 100644 --- a/lib/features/player/view/audiobook_player.dart +++ b/lib/features/player/view/audiobook_player.dart @@ -98,7 +98,7 @@ class AudiobookPlayer extends HookConsumerWidget { // add a delay before closing the player // to allow the user to see the player closing Future.delayed(const Duration(milliseconds: 300), () { - player.setSourceAudioBook(null); + player.setSourceAudiobook(null); }); }, curve: Curves.easeOut, diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index a5fc097..fdcf4eb 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -17,6 +17,8 @@ import 'widgets/audiobook_player_seek_button.dart'; import 'widgets/audiobook_player_seek_chapter_button.dart'; import 'widgets/player_speed_adjust_button.dart'; +var pendingPlayerModals = 0; + class PlayerWhenExpanded extends HookConsumerWidget { const PlayerWhenExpanded({ super.key, @@ -270,6 +272,7 @@ class SleepTimerButton extends HookConsumerWidget { message: 'Sleep Timer', child: InkWell( onTap: () async { + pendingPlayerModals++; // show the sleep timer dialog final resultingDuration = await showDurationPicker( context: context, @@ -279,6 +282,7 @@ class SleepTimerButton extends HookConsumerWidget { .sleepTimerSettings .defaultDuration, ); + pendingPlayerModals--; if (resultingDuration != null) { // if 0 is selected, cancel the timer if (resultingDuration.inSeconds == 0) { diff --git a/lib/features/player/view/widgets/player_speed_adjust_button.dart b/lib/features/player/view/widgets/player_speed_adjust_button.dart index 3248967..7f8295e 100644 --- a/lib/features/player/view/widgets/player_speed_adjust_button.dart +++ b/lib/features/player/view/widgets/player_speed_adjust_button.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; +import 'package:whispering_pages/features/player/view/player_when_expanded.dart'; import 'package:whispering_pages/features/player/view/widgets/speed_selector.dart'; +final _logger = Logger('PlayerSpeedAdjustButton'); + class PlayerSpeedAdjustButton extends HookConsumerWidget { const PlayerSpeedAdjustButton({ super.key, @@ -14,8 +18,10 @@ class PlayerSpeedAdjustButton extends HookConsumerWidget { final notifier = ref.watch(audiobookPlayerProvider.notifier); return TextButton( child: Text('${player.speed}x'), - onPressed: () { - showModalBottomSheet( + onPressed: () async { + pendingPlayerModals++; + _logger.fine('opening speed selector'); + await showModalBottomSheet( context: context, barrierLabel: 'Select Speed', constraints: const BoxConstraints( @@ -29,6 +35,8 @@ class PlayerSpeedAdjustButton extends HookConsumerWidget { ); }, ); + pendingPlayerModals--; + _logger.fine('Closing speed selector'); }, ); } diff --git a/lib/features/sleep_timer/providers/sleep_timer_provider.dart b/lib/features/sleep_timer/providers/sleep_timer_provider.dart index 910a923..759ea1f 100644 --- a/lib/features/sleep_timer/providers/sleep_timer_provider.dart +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.dart @@ -30,8 +30,7 @@ class SleepTimer extends _$SleepTimer { } var sleepTimer = core.SleepTimer( - // duration: sleepTimerSettings.defaultDuration, - duration: const Duration(seconds: 5), + duration: sleepTimerSettings.defaultDuration, player: ref.watch(simpleAudiobookPlayerProvider), ); ref.onDispose(sleepTimer.dispose); diff --git a/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart b/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart index 08da8ce..ef930bf 100644 --- a/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart @@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$sleepTimerHash() => r'de2f39febda3c2234e792f64199c51828206ea9b'; +String _$sleepTimerHash() => r'ad77e82c1b513bbc62815c64ce1ed403f92fc055'; /// See also [SleepTimer]. @ProviderFor(SleepTimer) diff --git a/lib/main.dart b/lib/main.dart index 9454908..1588114 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ import 'package:just_audio_media_kit/just_audio_media_kit.dart' import 'package:logging/logging.dart'; import 'package:whispering_pages/api/server_provider.dart'; import 'package:whispering_pages/db/storage.dart'; +import 'package:whispering_pages/features/downloads/core/download_manager.dart'; +import 'package:whispering_pages/features/downloads/providers/download_manager.dart'; import 'package:whispering_pages/features/playback_reporting/providers/playback_reporter_provider.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; import 'package:whispering_pages/features/sleep_timer/providers/sleep_timer_provider.dart'; @@ -17,6 +19,7 @@ import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/duration_format.dart'; import 'package:whispering_pages/theme/theme.dart'; +final appLogger = Logger('whispering_pages'); void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -46,6 +49,9 @@ void main() async { androidNotificationOngoing: true, ); + // for initializing the download manager + await initDownloadManager(); + // run the app runApp( const ProviderScope( @@ -98,6 +104,7 @@ class _EagerInitialization extends ConsumerWidget { ref.watch(simpleAudiobookPlayerProvider); ref.watch(sleepTimerProvider); ref.watch(playbackReporterProvider); + ref.watch(simpleDownloadManagerProvider); } catch (e) { debugPrintStack(stackTrace: StackTrace.current, label: e.toString()); } diff --git a/lib/pages/navigation.dart b/lib/pages/navigation.dart deleted file mode 100644 index 400e3df..0000000 --- a/lib/pages/navigation.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class AppNavigation extends HookConsumerWidget { - const AppNavigation({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final currentIndex = useState(0); - return Scaffold( - bottomNavigationBar: BottomNavigationBar( - currentIndex: currentIndex.value, - onTap: (value) => currentIndex.value = value, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.business), - label: 'Business', - ), - BottomNavigationBarItem( - icon: Icon(Icons.school), - label: 'School', - ), - ], - ), - ); - } -} diff --git a/lib/router/constants.dart b/lib/router/constants.dart index fe46d5a..fde72b8 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -43,6 +43,19 @@ class Routes { pathName: 'explore', name: 'explore', ); + + // downloads + static const downloads = _SimpleRoute( + pathName: 'downloads', + name: 'downloads', + ); + + // library browser to browse the library using author, genre, etc. + static const libraryBrowser = _SimpleRoute( + pathName: 'browser', + name: 'libraryBrowser', + // parentRoute: library, + ); } // a class to store path diff --git a/lib/router/router.dart b/lib/router/router.dart index c4d9cca..f06964e 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:whispering_pages/features/downloads/view/downloads_page.dart'; import 'package:whispering_pages/features/explore/view/explore_page.dart'; import 'package:whispering_pages/features/explore/view/search_result_page.dart'; import 'package:whispering_pages/features/item_viewer/view/library_item_page.dart'; +import 'package:whispering_pages/features/library_browser/view/library_browser_page.dart'; import 'package:whispering_pages/features/onboarding/view/onboarding_single_page.dart'; import 'package:whispering_pages/pages/home_page.dart'; import 'package:whispering_pages/settings/view/app_settings_page.dart'; @@ -13,9 +15,9 @@ import 'transitions/slide.dart'; part 'constants.dart'; -final GlobalKey _rootNavigatorKey = +final GlobalKey rootNavigatorKey = GlobalKey(debugLabel: 'root'); -final GlobalKey _sectionHomeNavigatorKey = +final GlobalKey sectionHomeNavigatorKey = GlobalKey(debugLabel: 'HomeNavigator'); // GoRouter configuration @@ -47,7 +49,7 @@ class MyAppRouter { branches: [ // The route branch for the first tab of the bottom navigation bar. StatefulShellBranch( - navigatorKey: _sectionHomeNavigatorKey, + navigatorKey: sectionHomeNavigatorKey, routes: [ GoRoute( path: Routes.home.path, @@ -76,6 +78,23 @@ class MyAppRouter { ); }, ), + // downloads page + GoRoute( + path: Routes.downloads.path, + name: Routes.downloads.name, + pageBuilder: defaultPageBuilder(const DownloadsPage()), + ), + ], + ), + + // Library page + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.libraryBrowser.path, + name: Routes.libraryBrowser.name, + pageBuilder: defaultPageBuilder(const LibraryBrowserPage()), + ), ], ), // search/explore page diff --git a/lib/router/scaffold_with_nav_bar.dart b/lib/router/scaffold_with_nav_bar.dart index 694360a..d5484be 100644 --- a/lib/router/scaffold_with_nav_bar.dart +++ b/lib/router/scaffold_with_nav_bar.dart @@ -5,6 +5,7 @@ import 'package:miniplayer/miniplayer.dart'; import 'package:whispering_pages/features/explore/providers/search_controller.dart'; import 'package:whispering_pages/features/player/providers/player_form.dart'; import 'package:whispering_pages/features/player/view/audiobook_player.dart'; +import 'package:whispering_pages/features/player/view/player_when_expanded.dart'; // stack to track changes in navigationShell.currentIndex // home is always at index 0 and at the start and should be the last before popping @@ -39,10 +40,11 @@ class ScaffoldWithNavBar extends HookConsumerWidget { final isPlayerExpanded = playerProgress != playerMinHeight; debugPrint( - 'BackButtonListener: Back button pressed, isPlayerExpanded: $isPlayerExpanded, stack: $navigationShellStack', + 'BackButtonListener: Back button pressed, isPlayerExpanded: $isPlayerExpanded, stack: $navigationShellStack, pendingPlayerModals: $pendingPlayerModals', ); + // close miniplayer if it is open - if (isPlayerExpanded) { + if (isPlayerExpanded && pendingPlayerModals == 0) { debugPrint( 'BackButtonListener: closing the player', ); @@ -83,6 +85,7 @@ class ScaffoldWithNavBar extends HookConsumerWidget { return false; } + // TODO: Implement a better way to handle back button presses to minimize player return BackButtonListener( onBackButtonPressed: onBackButtonPressed, child: Scaffold( @@ -161,7 +164,7 @@ class ScaffoldWithNavBar extends HookConsumerWidget { // If it is, debugPrint a message to the console. if (index == navigationShell.currentIndex) { // if current branch is explore, open the search view - if (index == 1) { + if (index == 2) { final searchController = ref.read(globalSearchControllerProvider); // open the search view if not already open if (!searchController.isOpen) { @@ -183,6 +186,12 @@ const _navigationItems = [ icon: Icons.home_outlined, activeIcon: Icons.home, ), + // Library + _NavigationItem( + name: 'Library', + icon: Icons.book_outlined, + activeIcon: Icons.book, + ), _NavigationItem( name: 'Explore', icon: Icons.search_outlined, diff --git a/lib/settings/api_settings_provider.g.dart b/lib/settings/api_settings_provider.g.dart index e2e673c..0bfacbf 100644 --- a/lib/settings/api_settings_provider.g.dart +++ b/lib/settings/api_settings_provider.g.dart @@ -6,7 +6,7 @@ part of 'api_settings_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$apiSettingsHash() => r'b009ae0d14203a15abaa497287fc68f57eb86bde'; +String _$apiSettingsHash() => r'26e7e09e7369bac9fbf0589da9fd97d1f15b7926'; /// See also [ApiSettings]. @ProviderFor(ApiSettings) diff --git a/lib/settings/app_settings_provider.dart b/lib/settings/app_settings_provider.dart index 142019a..18c728b 100644 --- a/lib/settings/app_settings_provider.dart +++ b/lib/settings/app_settings_provider.dart @@ -11,6 +11,20 @@ final _box = AvailableHiveBoxes.userPrefsBox; final _logger = Logger('AppSettingsProvider'); +model.AppSettings readFromBoxOrCreate() { + // see if the settings are already in the box + if (_box.isNotEmpty) { + final foundSettings = _box.getAt(0); + _logger.fine('found settings in box: $foundSettings'); + return foundSettings; + } else { + // create a new settings object + const settings = model.AppSettings(); + _logger.fine('created new settings: $settings'); + return settings; + } +} + @Riverpod(keepAlive: true) class AppSettings extends _$AppSettings { @override @@ -22,20 +36,6 @@ class AppSettings extends _$AppSettings { return state; } - model.AppSettings readFromBoxOrCreate() { - // see if the settings are already in the box - if (_box.isNotEmpty) { - final foundSettings = _box.getAt(0); - _logger.fine('found settings in box: $foundSettings'); - return foundSettings; - } else { - // create a new settings object - const settings = model.AppSettings(); - _logger.fine('created new settings: $settings'); - return settings; - } - } - // write the settings to the box void writeToBox() { _box.clear(); @@ -50,4 +50,8 @@ class AppSettings extends _$AppSettings { void updateState(model.AppSettings newSettings) { state = newSettings; } + + void reset() { + state = const model.AppSettings(); + } } diff --git a/lib/settings/app_settings_provider.g.dart b/lib/settings/app_settings_provider.g.dart index e903f2e..2927d74 100644 --- a/lib/settings/app_settings_provider.g.dart +++ b/lib/settings/app_settings_provider.g.dart @@ -6,7 +6,7 @@ part of 'app_settings_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$appSettingsHash() => r'6716bc568850ffd373fd8572c5781beefafbb9ee'; +String _$appSettingsHash() => r'99bd35aff3c02252a4013c674fd885e841a7f703'; /// See also [AppSettings]. @ProviderFor(AppSettings) diff --git a/lib/settings/models/app_settings.dart b/lib/settings/models/app_settings.dart index b57b9f7..565b35d 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -12,8 +12,9 @@ part 'app_settings.g.dart'; class AppSettings with _$AppSettings { const factory AppSettings({ @Default(true) bool isDarkMode, - @Default(false) bool useMaterialThemeOnItemPage, + @Default(true) bool useMaterialThemeOnItemPage, @Default(PlayerSettings()) PlayerSettings playerSettings, + @Default(DownloadSettings()) DownloadSettings downloadSettings, }) = _AppSettings; factory AppSettings.fromJson(Map json) => @@ -105,3 +106,18 @@ class SleepTimerSettings with _$SleepTimerSettings { factory SleepTimerSettings.fromJson(Map json) => _$SleepTimerSettingsFromJson(json); } + +@freezed +class DownloadSettings with _$DownloadSettings { + const factory DownloadSettings({ + @Default(true) bool requiresWiFi, + @Default(3) int retries, + @Default(true) bool allowPause, + @Default(3) int maxConcurrent, + @Default(3) int maxConcurrentByHost, + @Default(3) int maxConcurrentByGroup, + }) = _DownloadSettings; + + factory DownloadSettings.fromJson(Map json) => + _$DownloadSettingsFromJson(json); +} diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index e48139b..86c45f9 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -23,6 +23,7 @@ mixin _$AppSettings { bool get isDarkMode => throw _privateConstructorUsedError; bool get useMaterialThemeOnItemPage => throw _privateConstructorUsedError; PlayerSettings get playerSettings => throw _privateConstructorUsedError; + DownloadSettings get downloadSettings => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -39,9 +40,11 @@ abstract class $AppSettingsCopyWith<$Res> { $Res call( {bool isDarkMode, bool useMaterialThemeOnItemPage, - PlayerSettings playerSettings}); + PlayerSettings playerSettings, + DownloadSettings downloadSettings}); $PlayerSettingsCopyWith<$Res> get playerSettings; + $DownloadSettingsCopyWith<$Res> get downloadSettings; } /// @nodoc @@ -60,6 +63,7 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> Object? isDarkMode = null, Object? useMaterialThemeOnItemPage = null, Object? playerSettings = null, + Object? downloadSettings = null, }) { return _then(_value.copyWith( isDarkMode: null == isDarkMode @@ -74,6 +78,10 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> ? _value.playerSettings : playerSettings // ignore: cast_nullable_to_non_nullable as PlayerSettings, + downloadSettings: null == downloadSettings + ? _value.downloadSettings + : downloadSettings // ignore: cast_nullable_to_non_nullable + as DownloadSettings, ) as $Val); } @@ -84,6 +92,14 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> return _then(_value.copyWith(playerSettings: value) as $Val); }); } + + @override + @pragma('vm:prefer-inline') + $DownloadSettingsCopyWith<$Res> get downloadSettings { + return $DownloadSettingsCopyWith<$Res>(_value.downloadSettings, (value) { + return _then(_value.copyWith(downloadSettings: value) as $Val); + }); + } } /// @nodoc @@ -97,10 +113,13 @@ abstract class _$$AppSettingsImplCopyWith<$Res> $Res call( {bool isDarkMode, bool useMaterialThemeOnItemPage, - PlayerSettings playerSettings}); + PlayerSettings playerSettings, + DownloadSettings downloadSettings}); @override $PlayerSettingsCopyWith<$Res> get playerSettings; + @override + $DownloadSettingsCopyWith<$Res> get downloadSettings; } /// @nodoc @@ -117,6 +136,7 @@ class __$$AppSettingsImplCopyWithImpl<$Res> Object? isDarkMode = null, Object? useMaterialThemeOnItemPage = null, Object? playerSettings = null, + Object? downloadSettings = null, }) { return _then(_$AppSettingsImpl( isDarkMode: null == isDarkMode @@ -131,6 +151,10 @@ class __$$AppSettingsImplCopyWithImpl<$Res> ? _value.playerSettings : playerSettings // ignore: cast_nullable_to_non_nullable as PlayerSettings, + downloadSettings: null == downloadSettings + ? _value.downloadSettings + : downloadSettings // ignore: cast_nullable_to_non_nullable + as DownloadSettings, )); } } @@ -140,8 +164,9 @@ class __$$AppSettingsImplCopyWithImpl<$Res> class _$AppSettingsImpl implements _AppSettings { const _$AppSettingsImpl( {this.isDarkMode = true, - this.useMaterialThemeOnItemPage = false, - this.playerSettings = const PlayerSettings()}); + this.useMaterialThemeOnItemPage = true, + this.playerSettings = const PlayerSettings(), + this.downloadSettings = const DownloadSettings()}); factory _$AppSettingsImpl.fromJson(Map json) => _$$AppSettingsImplFromJson(json); @@ -155,10 +180,13 @@ class _$AppSettingsImpl implements _AppSettings { @override @JsonKey() final PlayerSettings playerSettings; + @override + @JsonKey() + final DownloadSettings downloadSettings; @override String toString() { - return 'AppSettings(isDarkMode: $isDarkMode, useMaterialThemeOnItemPage: $useMaterialThemeOnItemPage, playerSettings: $playerSettings)'; + return 'AppSettings(isDarkMode: $isDarkMode, useMaterialThemeOnItemPage: $useMaterialThemeOnItemPage, playerSettings: $playerSettings, downloadSettings: $downloadSettings)'; } @override @@ -173,13 +201,15 @@ class _$AppSettingsImpl implements _AppSettings { other.useMaterialThemeOnItemPage == useMaterialThemeOnItemPage) && (identical(other.playerSettings, playerSettings) || - other.playerSettings == playerSettings)); + other.playerSettings == playerSettings) && + (identical(other.downloadSettings, downloadSettings) || + other.downloadSettings == downloadSettings)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash( - runtimeType, isDarkMode, useMaterialThemeOnItemPage, playerSettings); + int get hashCode => Object.hash(runtimeType, isDarkMode, + useMaterialThemeOnItemPage, playerSettings, downloadSettings); @JsonKey(ignore: true) @override @@ -199,7 +229,8 @@ abstract class _AppSettings implements AppSettings { const factory _AppSettings( {final bool isDarkMode, final bool useMaterialThemeOnItemPage, - final PlayerSettings playerSettings}) = _$AppSettingsImpl; + final PlayerSettings playerSettings, + final DownloadSettings downloadSettings}) = _$AppSettingsImpl; factory _AppSettings.fromJson(Map json) = _$AppSettingsImpl.fromJson; @@ -211,6 +242,8 @@ abstract class _AppSettings implements AppSettings { @override PlayerSettings get playerSettings; @override + DownloadSettings get downloadSettings; + @override @JsonKey(ignore: true) _$$AppSettingsImplCopyWith<_$AppSettingsImpl> get copyWith => throw _privateConstructorUsedError; @@ -1340,3 +1373,256 @@ abstract class _SleepTimerSettings implements SleepTimerSettings { _$$SleepTimerSettingsImplCopyWith<_$SleepTimerSettingsImpl> get copyWith => throw _privateConstructorUsedError; } + +DownloadSettings _$DownloadSettingsFromJson(Map json) { + return _DownloadSettings.fromJson(json); +} + +/// @nodoc +mixin _$DownloadSettings { + bool get requiresWiFi => throw _privateConstructorUsedError; + int get retries => throw _privateConstructorUsedError; + bool get allowPause => throw _privateConstructorUsedError; + int get maxConcurrent => throw _privateConstructorUsedError; + int get maxConcurrentByHost => throw _privateConstructorUsedError; + int get maxConcurrentByGroup => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $DownloadSettingsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $DownloadSettingsCopyWith<$Res> { + factory $DownloadSettingsCopyWith( + DownloadSettings value, $Res Function(DownloadSettings) then) = + _$DownloadSettingsCopyWithImpl<$Res, DownloadSettings>; + @useResult + $Res call( + {bool requiresWiFi, + int retries, + bool allowPause, + int maxConcurrent, + int maxConcurrentByHost, + int maxConcurrentByGroup}); +} + +/// @nodoc +class _$DownloadSettingsCopyWithImpl<$Res, $Val extends DownloadSettings> + implements $DownloadSettingsCopyWith<$Res> { + _$DownloadSettingsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? requiresWiFi = null, + Object? retries = null, + Object? allowPause = null, + Object? maxConcurrent = null, + Object? maxConcurrentByHost = null, + Object? maxConcurrentByGroup = null, + }) { + return _then(_value.copyWith( + requiresWiFi: null == requiresWiFi + ? _value.requiresWiFi + : requiresWiFi // ignore: cast_nullable_to_non_nullable + as bool, + retries: null == retries + ? _value.retries + : retries // ignore: cast_nullable_to_non_nullable + as int, + allowPause: null == allowPause + ? _value.allowPause + : allowPause // ignore: cast_nullable_to_non_nullable + as bool, + maxConcurrent: null == maxConcurrent + ? _value.maxConcurrent + : maxConcurrent // ignore: cast_nullable_to_non_nullable + as int, + maxConcurrentByHost: null == maxConcurrentByHost + ? _value.maxConcurrentByHost + : maxConcurrentByHost // ignore: cast_nullable_to_non_nullable + as int, + maxConcurrentByGroup: null == maxConcurrentByGroup + ? _value.maxConcurrentByGroup + : maxConcurrentByGroup // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$DownloadSettingsImplCopyWith<$Res> + implements $DownloadSettingsCopyWith<$Res> { + factory _$$DownloadSettingsImplCopyWith(_$DownloadSettingsImpl value, + $Res Function(_$DownloadSettingsImpl) then) = + __$$DownloadSettingsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool requiresWiFi, + int retries, + bool allowPause, + int maxConcurrent, + int maxConcurrentByHost, + int maxConcurrentByGroup}); +} + +/// @nodoc +class __$$DownloadSettingsImplCopyWithImpl<$Res> + extends _$DownloadSettingsCopyWithImpl<$Res, _$DownloadSettingsImpl> + implements _$$DownloadSettingsImplCopyWith<$Res> { + __$$DownloadSettingsImplCopyWithImpl(_$DownloadSettingsImpl _value, + $Res Function(_$DownloadSettingsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? requiresWiFi = null, + Object? retries = null, + Object? allowPause = null, + Object? maxConcurrent = null, + Object? maxConcurrentByHost = null, + Object? maxConcurrentByGroup = null, + }) { + return _then(_$DownloadSettingsImpl( + requiresWiFi: null == requiresWiFi + ? _value.requiresWiFi + : requiresWiFi // ignore: cast_nullable_to_non_nullable + as bool, + retries: null == retries + ? _value.retries + : retries // ignore: cast_nullable_to_non_nullable + as int, + allowPause: null == allowPause + ? _value.allowPause + : allowPause // ignore: cast_nullable_to_non_nullable + as bool, + maxConcurrent: null == maxConcurrent + ? _value.maxConcurrent + : maxConcurrent // ignore: cast_nullable_to_non_nullable + as int, + maxConcurrentByHost: null == maxConcurrentByHost + ? _value.maxConcurrentByHost + : maxConcurrentByHost // ignore: cast_nullable_to_non_nullable + as int, + maxConcurrentByGroup: null == maxConcurrentByGroup + ? _value.maxConcurrentByGroup + : maxConcurrentByGroup // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$DownloadSettingsImpl implements _DownloadSettings { + const _$DownloadSettingsImpl( + {this.requiresWiFi = true, + this.retries = 3, + this.allowPause = true, + this.maxConcurrent = 3, + this.maxConcurrentByHost = 3, + this.maxConcurrentByGroup = 3}); + + factory _$DownloadSettingsImpl.fromJson(Map json) => + _$$DownloadSettingsImplFromJson(json); + + @override + @JsonKey() + final bool requiresWiFi; + @override + @JsonKey() + final int retries; + @override + @JsonKey() + final bool allowPause; + @override + @JsonKey() + final int maxConcurrent; + @override + @JsonKey() + final int maxConcurrentByHost; + @override + @JsonKey() + final int maxConcurrentByGroup; + + @override + String toString() { + return 'DownloadSettings(requiresWiFi: $requiresWiFi, retries: $retries, allowPause: $allowPause, maxConcurrent: $maxConcurrent, maxConcurrentByHost: $maxConcurrentByHost, maxConcurrentByGroup: $maxConcurrentByGroup)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$DownloadSettingsImpl && + (identical(other.requiresWiFi, requiresWiFi) || + other.requiresWiFi == requiresWiFi) && + (identical(other.retries, retries) || other.retries == retries) && + (identical(other.allowPause, allowPause) || + other.allowPause == allowPause) && + (identical(other.maxConcurrent, maxConcurrent) || + other.maxConcurrent == maxConcurrent) && + (identical(other.maxConcurrentByHost, maxConcurrentByHost) || + other.maxConcurrentByHost == maxConcurrentByHost) && + (identical(other.maxConcurrentByGroup, maxConcurrentByGroup) || + other.maxConcurrentByGroup == maxConcurrentByGroup)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, requiresWiFi, retries, + allowPause, maxConcurrent, maxConcurrentByHost, maxConcurrentByGroup); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith => + __$$DownloadSettingsImplCopyWithImpl<_$DownloadSettingsImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$DownloadSettingsImplToJson( + this, + ); + } +} + +abstract class _DownloadSettings implements DownloadSettings { + const factory _DownloadSettings( + {final bool requiresWiFi, + final int retries, + final bool allowPause, + final int maxConcurrent, + final int maxConcurrentByHost, + final int maxConcurrentByGroup}) = _$DownloadSettingsImpl; + + factory _DownloadSettings.fromJson(Map json) = + _$DownloadSettingsImpl.fromJson; + + @override + bool get requiresWiFi; + @override + int get retries; + @override + bool get allowPause; + @override + int get maxConcurrent; + @override + int get maxConcurrentByHost; + @override + int get maxConcurrentByGroup; + @override + @JsonKey(ignore: true) + _$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index 434f306..6e4c0e7 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -10,11 +10,15 @@ _$AppSettingsImpl _$$AppSettingsImplFromJson(Map json) => _$AppSettingsImpl( isDarkMode: json['isDarkMode'] as bool? ?? true, useMaterialThemeOnItemPage: - json['useMaterialThemeOnItemPage'] as bool? ?? false, + json['useMaterialThemeOnItemPage'] as bool? ?? true, playerSettings: json['playerSettings'] == null ? const PlayerSettings() : PlayerSettings.fromJson( json['playerSettings'] as Map), + downloadSettings: json['downloadSettings'] == null + ? const DownloadSettings() + : DownloadSettings.fromJson( + json['downloadSettings'] as Map), ); Map _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => @@ -22,6 +26,7 @@ Map _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => 'isDarkMode': instance.isDarkMode, 'useMaterialThemeOnItemPage': instance.useMaterialThemeOnItemPage, 'playerSettings': instance.playerSettings, + 'downloadSettings': instance.downloadSettings, }; _$PlayerSettingsImpl _$$PlayerSettingsImplFromJson(Map json) => @@ -155,3 +160,26 @@ const _$SleepTimerShakeSenseModeEnumMap = { SleepTimerShakeSenseMode.always: 'always', SleepTimerShakeSenseMode.nearEnds: 'nearEnds', }; + +_$DownloadSettingsImpl _$$DownloadSettingsImplFromJson( + Map json) => + _$DownloadSettingsImpl( + requiresWiFi: json['requiresWiFi'] as bool? ?? true, + retries: (json['retries'] as num?)?.toInt() ?? 3, + allowPause: json['allowPause'] as bool? ?? true, + maxConcurrent: (json['maxConcurrent'] as num?)?.toInt() ?? 3, + maxConcurrentByHost: (json['maxConcurrentByHost'] as num?)?.toInt() ?? 3, + maxConcurrentByGroup: + (json['maxConcurrentByGroup'] as num?)?.toInt() ?? 3, + ); + +Map _$$DownloadSettingsImplToJson( + _$DownloadSettingsImpl instance) => + { + 'requiresWiFi': instance.requiresWiFi, + 'retries': instance.retries, + 'allowPause': instance.allowPause, + 'maxConcurrent': instance.maxConcurrent, + 'maxConcurrentByHost': instance.maxConcurrentByHost, + 'maxConcurrentByGroup': instance.maxConcurrentByGroup, + }; diff --git a/lib/settings/view/app_settings_page.dart b/lib/settings/view/app_settings_page.dart index 2c9f342..591a987 100644 --- a/lib/settings/view/app_settings_page.dart +++ b/lib/settings/view/app_settings_page.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:go_router/go_router.dart'; @@ -7,6 +10,7 @@ import 'package:whispering_pages/api/authenticated_user_provider.dart'; import 'package:whispering_pages/api/server_provider.dart'; import 'package:whispering_pages/router/router.dart'; import 'package:whispering_pages/settings/app_settings_provider.dart'; +import 'package:whispering_pages/settings/models/app_settings.dart' as model; class AppSettingsPage extends HookConsumerWidget { const AppSettingsPage({ @@ -20,7 +24,6 @@ class AppSettingsPage extends HookConsumerWidget { final registeredServersAsList = registeredServers.toList(); final availableUsers = ref.watch(authenticatedUserProvider); final serverURIController = useTextEditingController(); - final formKey = GlobalKey(); final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; return Scaffold( @@ -124,6 +127,149 @@ class AppSettingsPage extends HookConsumerWidget { ), ], ), + + // Backup and Restore section + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + title: Text( + 'Backup and Restore', + style: Theme.of(context).textTheme.titleLarge, + ), + tiles: [ + SettingsTile( + title: const Text('Copy to Clipboard'), + leading: const Icon(Icons.copy), + description: const Text( + 'Copy the app settings to the clipboard', + ), + onPressed: (context) async { + // copy to clipboard + await Clipboard.setData( + ClipboardData( + text: jsonEncode(appSettings.toJson()), + ), + ); + // show toast + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Settings copied to clipboard'), + ), + ); + }, + ), + SettingsTile( + title: const Text('Restore'), + leading: const Icon(Icons.restore), + description: const Text( + 'Restore the app settings from the backup', + ), + onPressed: (context) { + final formKey = GlobalKey(); + // show a dialog to get the backup + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Restore Backup'), + content: Form( + key: formKey, + child: TextFormField( + controller: serverURIController, + decoration: const InputDecoration( + labelText: 'Backup', + hintText: 'Paste the backup here', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please paste the backup here'; + } + return null; + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (formKey.currentState!.validate()) { + final backup = serverURIController.text; + final newSettings = model.AppSettings.fromJson( + // decode the backup as json + jsonDecode(backup), + ); + ref + .read(appSettingsProvider.notifier) + .updateState(newSettings); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Settings restored'), + ), + ); + // clear the backup + serverURIController.clear(); + } + }, + child: const Text('Restore'), + ), + ], + ); + }, + ); + }, + ), + + // a button to reset the app settings + SettingsTile( + title: const Text('Reset App Settings'), + leading: const Icon(Icons.settings_backup_restore), + description: const Text( + 'Reset the app settings to the default values', + ), + onPressed: (context) async { + // confirm the reset + final res = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Reset App Settings'), + content: const Text( + 'Are you sure you want to reset the app settings?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text('Reset'), + ), + ], + ); + }, + ); + + // if the user confirms the reset + if (res == true) { + ref.read(appSettingsProvider.notifier).reset(); + } + }, + ), + ], + ), ], ), ); diff --git a/lib/shared/extensions/model_conversions.dart b/lib/shared/extensions/model_conversions.dart index def0639..38403d8 100644 --- a/lib/shared/extensions/model_conversions.dart +++ b/lib/shared/extensions/model_conversions.dart @@ -51,3 +51,31 @@ extension UserConversion on User { UserWithSessionAndMostRecentProgress.fromJson(toJson()); User get asUser => User.fromJson(toJson()); } + +extension ContentUrl on LibraryFile { + Uri url(String baseUrl, String itemId, String token) { + // /api/items/{itemId}/file/{ino}?{token} + // return Uri.parse('$baseUrl/api/items/$itemId/file/$ino?token=$token'); + var baseUri = Uri.parse(baseUrl); + return Uri( + scheme: baseUri.scheme, + host: baseUri.host, + path: '/api/items/$itemId/file/$ino', + queryParameters: {'token': token}, + ); + } + + Uri downloadUrl(String baseUrl, String itemId, String token) { + // /api/items/{itemId}/file/{ino}/download?{token} + // return Uri.parse( + // '$baseUrl/api/items/$itemId/file/$ino/download?token=$token', + // ); + var baseUri = Uri.parse(baseUrl); + return Uri( + scheme: baseUri.scheme, + host: baseUri.host, + path: '/api/items/$itemId/file/$ino/download', + queryParameters: {'token': token}, + ); + } +} diff --git a/lib/theme/theme_from_cover_provider.g.dart b/lib/theme/theme_from_cover_provider.g.dart index a2c3594..8201917 100644 --- a/lib/theme/theme_from_cover_provider.g.dart +++ b/lib/theme/theme_from_cover_provider.g.dart @@ -6,7 +6,7 @@ part of 'theme_from_cover_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$themeFromCoverHash() => r'bb4c5f32dfe7b6da6f43b8d002267d554cdf98ec'; +String _$themeFromCoverHash() => r'a549513a0dcdff76be94488baf38a8b886ce63eb'; /// Copied from Dart SDK class _SystemHash { diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..db02f86 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ