diff --git a/assets/images/undraw_set_preferences_kwia.svg b/assets/images/undraw_set_preferences_kwia.svg new file mode 100644 index 0000000..1de3e70 --- /dev/null +++ b/assets/images/undraw_set_preferences_kwia.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/undraw_time_management_re_tk5w.svg b/assets/images/undraw_time_management_re_tk5w.svg new file mode 100644 index 0000000..430dea6 --- /dev/null +++ b/assets/images/undraw_time_management_re_tk5w.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index 2fb9cfe..f12de3e 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:duration_picker/duration_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,7 +8,9 @@ import 'package:whispering_pages/constants/sizes.dart'; import 'package:whispering_pages/features/player/providers/currently_playing_provider.dart'; import 'package:whispering_pages/features/player/view/audiobook_player.dart'; import 'package:whispering_pages/features/sleep_timer/core/sleep_timer.dart'; -import 'package:whispering_pages/features/sleep_timer/providers/sleep_timer_provider.dart'; +import 'package:whispering_pages/features/sleep_timer/providers/sleep_timer_provider.dart' + show sleepTimerProvider; +import 'package:whispering_pages/settings/app_settings_provider.dart'; import 'package:whispering_pages/shared/extensions/inverse_lerp.dart'; import 'widgets/audiobook_player_seek_button.dart'; @@ -227,17 +230,84 @@ class SleepTimerButton extends HookConsumerWidget { final sleepTimer = ref.watch(sleepTimerProvider); // if sleep timer is not active, show the button with the sleep timer icon // if the sleep timer is active, show the remaining time in a pill shaped container - return sleepTimer == null - ? IconButton( - color: sleepTimer != null - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - icon: const Icon(Icons.timer_rounded), - onPressed: () {}, - ) - : RemainingSleepTimeDisplay( - timer: sleepTimer, + return Tooltip( + message: 'Sleep Timer', + child: InkWell( + onTap: () async { + // show the sleep timer dialog + final resultingDuration = await showDurationPicker( + context: context, + initialTime: ref + .watch(appSettingsProvider) + .playerSettings + .sleepTimerSettings + .defaultDuration, ); + if (resultingDuration != null) { + // if 0 is selected, cancel the timer + if (resultingDuration.inSeconds == 0) { + ref.read(sleepTimerProvider.notifier).cancelTimer(); + } else { + ref.read(sleepTimerProvider.notifier).setTimer(resultingDuration); + } + } + }, + child: sleepTimer == null + ? Icon( + Icons.timer_rounded, + color: Theme.of(context).colorScheme.onSurface, + ) + : RemainingSleepTimeDisplay( + timer: sleepTimer, + ), + ), + ); + } +} + +class SleepTimerDialog extends HookConsumerWidget { + const SleepTimerDialog({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sleepTimer = ref.watch(sleepTimerProvider); + final sleepTimerSettings = + ref.watch(appSettingsProvider).playerSettings.sleepTimerSettings; + final timerDurationController = useTextEditingController( + text: sleepTimerSettings.defaultDuration.inMinutes.toString(), + ); + + return AlertDialog( + title: const Text('Sleep Timer'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Set the duration for the sleep timer'), + TextField( + controller: timerDurationController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Duration in minutes', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + // sleepTimer.setTimer( + // Duration( + // minutes: int.tryParse(timerDurationController.text) ?? 0, + // ), + // ); + Navigator.of(context).pop(); + }, + child: const Text('Set Timer'), + ), + ], + ); } } diff --git a/lib/features/sleep_timer/core/sleep_timer.dart b/lib/features/sleep_timer/core/sleep_timer.dart index 7e85555..0283d51 100644 --- a/lib/features/sleep_timer/core/sleep_timer.dart +++ b/lib/features/sleep_timer/core/sleep_timer.dart @@ -11,7 +11,14 @@ import 'package:whispering_pages/features/player/core/audiobook_player.dart'; /// timer is cancelled when the music player is paused or stopped class SleepTimer { /// The duration after which the music player will be paused - final Duration duration; + Duration _duration; + + Duration get duration => _duration; + + set duration(Duration value) { + _duration = value; + reset(); + } /// The player to be paused final AudiobookPlayer player; @@ -23,22 +30,30 @@ class SleepTimer { /// when the timer was started DateTime? startedAt; - SleepTimer({required this.duration, required this.player}) { - player.playbackEventStream.listen((event) { - if (event.processingState == ProcessingState.completed || - event.processingState == ProcessingState.idle) { - reset(); - } - }); + /// subscriptions + final List _subscriptions = []; + + SleepTimer({required duration, required this.player}) : _duration = duration { + _subscriptions.add( + player.playbackEventStream.listen((event) { + if (event.processingState == ProcessingState.completed || + event.processingState == ProcessingState.idle) { + reset(); + } + }), + ); /// pause the player when the timer is up - player.playerStateStream.listen((state) { - if (state.playing && timer == null) { - startTimer(); - } else if (!state.playing) { - reset(); - } - }); + _subscriptions.add( + player.playerStateStream.listen((state) { + if (state.playing && timer == null) { + startTimer(); + } else if (!state.playing) { + reset(); + } + }), + ); + debugPrint('SleepTimer created with duration: $duration'); } @@ -53,9 +68,12 @@ class SleepTimer { } } - /// starts the timer - void startTimer() { + /// starts the timer with the given duration or the default duration + void startTimer([ + Duration? forDuration, + ]) { reset(); + duration = forDuration ?? duration; timer = Timer(duration, () { player.pause(); reset(); @@ -84,6 +102,9 @@ class SleepTimer { /// dispose the timer void dispose() { reset(); + for (var sub in _subscriptions) { + sub.cancel(); + } debugPrint('SleepTimer disposed'); } } diff --git a/lib/features/sleep_timer/providers/sleep_timer_provider.dart b/lib/features/sleep_timer/providers/sleep_timer_provider.dart index e173334..910a923 100644 --- a/lib/features/sleep_timer/providers/sleep_timer_provider.dart +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.dart @@ -1,19 +1,59 @@ +import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:whispering_pages/features/player/providers/audiobook_player.dart'; -import 'package:whispering_pages/features/sleep_timer/core/sleep_timer.dart'; +import 'package:whispering_pages/features/sleep_timer/core/sleep_timer.dart' + as core; import 'package:whispering_pages/settings/app_settings_provider.dart'; +import 'package:whispering_pages/shared/extensions/time_of_day.dart'; part 'sleep_timer_provider.g.dart'; @Riverpod(keepAlive: true) -SleepTimer? sleepTimer(SleepTimerRef ref) { - final appSettings = ref.watch(appSettingsProvider); - final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; - var sleepTimer = SleepTimer( - // duration: sleepTimerSettings.defaultDuration, - duration: const Duration(seconds: 5), - player: ref.watch(simpleAudiobookPlayerProvider), - ); - ref.onDispose(sleepTimer.dispose); - return sleepTimer; +class SleepTimer extends _$SleepTimer { + @override + core.SleepTimer? build() { + final appSettings = ref.watch(appSettingsProvider); + final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; + bool isEnabled = sleepTimerSettings.autoTurnOnTimer; + if (!isEnabled) { + return null; + } + + if ((!sleepTimerSettings.alwaysAutoTurnOnTimer) && + (sleepTimerSettings.autoTurnOnTime + .toTimeOfDay() + .isAfter(TimeOfDay.now()) && + sleepTimerSettings.autoTurnOffTime + .toTimeOfDay() + .isBefore(TimeOfDay.now()))) { + return null; + } + + var sleepTimer = core.SleepTimer( + // duration: sleepTimerSettings.defaultDuration, + duration: const Duration(seconds: 5), + player: ref.watch(simpleAudiobookPlayerProvider), + ); + ref.onDispose(sleepTimer.dispose); + return sleepTimer; + } + + void setTimer(Duration resultingDuration) { + if (state != null) { + state!.duration = resultingDuration; + ref.notifyListeners(); + } else { + final timer = core.SleepTimer( + duration: resultingDuration, + player: ref.watch(simpleAudiobookPlayerProvider), + ); + ref.onDispose(timer.dispose); + state = timer; + } + } + + void cancelTimer() { + state?.dispose(); + state = null; + } } 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 a08cede..08da8ce 100644 --- a/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.g.dart @@ -6,12 +6,13 @@ part of 'sleep_timer_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$sleepTimerHash() => r'79646b12412f3300166db29328664a5e58e405bd'; +String _$sleepTimerHash() => r'de2f39febda3c2234e792f64199c51828206ea9b'; -/// See also [sleepTimer]. -@ProviderFor(sleepTimer) -final sleepTimerProvider = Provider.internal( - sleepTimer, +/// See also [SleepTimer]. +@ProviderFor(SleepTimer) +final sleepTimerProvider = + NotifierProvider.internal( + SleepTimer.new, name: r'sleepTimerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$sleepTimerHash, @@ -19,6 +20,6 @@ final sleepTimerProvider = Provider.internal( allTransitiveDependencies: null, ); -typedef SleepTimerRef = ProviderRef; +typedef _$SleepTimer = 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/router/constants.dart b/lib/router/constants.dart index 5c22b1c..fe46d5a 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -21,10 +21,19 @@ class Routes { pathParamName: 'itemId', name: 'libraryItem', ); + + // settings static const settings = _SimpleRoute( pathName: 'config', name: 'settings', ); + static const autoSleepTimerSettings = _SimpleRoute( + pathName: 'autosleeptimer', + name: 'autoSleepTimerSettings', + // parentRoute: settings, + ); + + // search and explore static const search = _SimpleRoute( pathName: 'search', name: 'search', @@ -51,9 +60,12 @@ class _SimpleRoute { final String name; final _SimpleRoute? parentRoute; - String get path => - '${parentRoute?.path ?? ''}${parentRoute != null ? '/' : ''}$localPath'; + /// the full path of the route + String get path { + return '${parentRoute?.path ?? ''}$localPath'; + } + /// the local path of the route String get localPath => '/$pathName${pathParamName != null ? '/:$pathParamName' : ''}'; } diff --git a/lib/router/router.dart b/lib/router/router.dart index 95a3577..c4d9cca 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -4,8 +4,9 @@ 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/onboarding/view/onboarding_single_page.dart'; -import 'package:whispering_pages/pages/app_settings.dart'; import 'package:whispering_pages/pages/home_page.dart'; +import 'package:whispering_pages/settings/view/app_settings_page.dart'; +import 'package:whispering_pages/settings/view/auto_sleep_timer_settings_page.dart'; import 'scaffold_with_nav_bar.dart'; import 'transitions/slide.dart'; @@ -128,6 +129,15 @@ class MyAppRouter { // builder: (context, state) => const AppSettingsPage(), pageBuilder: defaultPageBuilder(const AppSettingsPage()), ), + GoRoute( + path: Routes.autoSleepTimerSettings.path, + name: Routes.autoSleepTimerSettings.name, + // builder: (context, state) => + // const AutoSleepTimerSettingsPage(), + pageBuilder: defaultPageBuilder( + const AutoSleepTimerSettingsPage(), + ), + ), ], ), ], diff --git a/lib/pages/app_settings.dart b/lib/settings/view/app_settings_page.dart similarity index 52% rename from lib/pages/app_settings.dart rename to lib/settings/view/app_settings_page.dart index 4393fa9..2c9f342 100644 --- a/lib/pages/app_settings.dart +++ b/lib/settings/view/app_settings_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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'; class AppSettingsPage extends HookConsumerWidget { @@ -19,6 +21,7 @@ class AppSettingsPage extends HookConsumerWidget { final availableUsers = ref.watch(authenticatedUserProvider); final serverURIController = useTextEditingController(); final formKey = GlobalKey(); + final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; return Scaffold( appBar: AppBar( @@ -26,6 +29,7 @@ class AppSettingsPage extends HookConsumerWidget { ), body: SettingsList( sections: [ + // Appearance section SettingsSection( margin: const EdgeInsetsDirectional.symmetric( horizontal: 16.0, @@ -55,7 +59,7 @@ class AppSettingsPage extends HookConsumerWidget { ), leading: appSettings.useMaterialThemeOnItemPage ? const Icon(Icons.auto_fix_high) - : const Icon(Icons.auto_fix_off), + : const Icon(Icons.auto_fix_off), onToggle: (value) { ref.read(appSettingsProvider.notifier).updateState( appSettings.copyWith( @@ -66,6 +70,60 @@ class AppSettingsPage extends HookConsumerWidget { ), ], ), + + // Sleep Timer section + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + title: Text( + 'Sleep Timer', + style: Theme.of(context).textTheme.titleLarge, + ), + tiles: [ + SettingsTile.navigation( + // initialValue: sleepTimerSettings.autoTurnOnTimer, + title: const Text('Auto Turn On Timer'), + description: const Text( + 'Automatically turn on the sleep timer based on the time of day', + ), + leading: sleepTimerSettings.autoTurnOnTimer + ? const Icon(Icons.timer) + : const Icon(Icons.timer_off), + onPressed: (context) { + // push the sleep timer settings page + context.pushNamed(Routes.autoSleepTimerSettings.name); + }, + // a switch to enable or disable the auto turn off time + trailing: IntrinsicHeight( + child: Row( + children: [ + VerticalDivider( + color: Theme.of(context).dividerColor.withOpacity(0.5), + indent: 8.0, + endIndent: 8.0, + // width: 8.0, + // thickness: 2.0, + // height: 24.0, + ), + Switch( + value: sleepTimerSettings.autoTurnOnTimer, + onChanged: (value) { + ref.read(appSettingsProvider.notifier).updateState( + appSettings.copyWith.playerSettings + .sleepTimerSettings( + autoTurnOnTimer: value, + ), + ); + }, + ), + ], + ), + ), + ), + ], + ), ], ), ); diff --git a/lib/settings/view/auto_sleep_timer_settings_page.dart b/lib/settings/view/auto_sleep_timer_settings_page.dart new file mode 100644 index 0000000..e22a19d --- /dev/null +++ b/lib/settings/view/auto_sleep_timer_settings_page.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:whispering_pages/settings/app_settings_provider.dart'; +import 'package:whispering_pages/shared/extensions/time_of_day.dart'; + +class AutoSleepTimerSettingsPage extends HookConsumerWidget { + const AutoSleepTimerSettingsPage({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appSettings = ref.watch(appSettingsProvider); + final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; + + return Scaffold( + appBar: AppBar( + title: const Text('Auto Sleep Timer Settings'), + ), + body: SettingsList( + sections: [ + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + tiles: [ + SettingsTile.switchTile( + // initialValue: sleepTimerSettings.autoTurnOnTimer, + title: const Text('Auto Turn On Timer'), + description: const Text( + 'Automatically turn on the sleep timer based on the time of day', + ), + leading: sleepTimerSettings.autoTurnOnTimer + ? const Icon(Icons.timer) + : const Icon(Icons.timer_off), + onToggle: (value) { + ref.read(appSettingsProvider.notifier).updateState( + appSettings.copyWith.playerSettings.sleepTimerSettings( + autoTurnOnTimer: value, + ), + ); + }, + initialValue: sleepTimerSettings.autoTurnOnTimer, + ), + // auto turn on time settings, enabled only when autoTurnOnTimer is enabled + SettingsTile.navigation( + enabled: sleepTimerSettings.autoTurnOnTimer, + title: const Text('Auto Turn On Time'), + description: const Text( + 'Turn on the sleep timer at the specified time', + ), + onPressed: (context) async { + // navigate to the time picker + final selected = await showTimePicker( + context: context, + initialTime: + sleepTimerSettings.autoTurnOnTime.toTimeOfDay(), + ); + if (selected != null) { + ref.read(appSettingsProvider.notifier).updateState( + appSettings.copyWith.playerSettings + .sleepTimerSettings( + autoTurnOnTime: selected.toDuration(), + ), + ); + } + }, + value: Text( + sleepTimerSettings.autoTurnOnTime + .toTimeOfDay() + .format(context), + ), + ), + SettingsTile.navigation( + title: const Text('Auto Turn Off Time'), + description: const Text( + 'Turn off the sleep timer at the specified time', + ), + enabled: sleepTimerSettings.autoTurnOnTimer, + onPressed: (context) async { + // navigate to the time picker + final selected = await showTimePicker( + context: context, + initialTime: + sleepTimerSettings.autoTurnOffTime.toTimeOfDay(), + ); + if (selected != null) { + ref.read(appSettingsProvider.notifier).updateState( + appSettings.copyWith.playerSettings + .sleepTimerSettings( + autoTurnOffTime: selected.toDuration(), + ), + ); + } + }, + value: Text( + sleepTimerSettings.autoTurnOffTime + .toTimeOfDay() + .format(context), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/extensions/time_of_day.dart b/lib/shared/extensions/time_of_day.dart new file mode 100644 index 0000000..702c5b6 --- /dev/null +++ b/lib/shared/extensions/time_of_day.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +extension ToTimeOfDay on Duration { + TimeOfDay toTimeOfDay() { + return TimeOfDay(hour: inHours, minute: inMinutes % 60); + } +} + +extension ToDuration on TimeOfDay { + Duration toDuration() { + return Duration(hours: hour, minutes: minute); + } +} + +extension TimeOfDayExtension on TimeOfDay { + int compareTo(TimeOfDay other) { + if (hour < other.hour) return -1; + if (hour > other.hour) return 1; + if (minute < other.minute) return -1; + if (minute > other.minute) return 1; + return 0; + } + + bool operator <(TimeOfDay other) => compareTo(other) < 0; + bool operator >(TimeOfDay other) => compareTo(other) > 0; + bool operator <=(TimeOfDay other) => compareTo(other) <= 0; + bool operator >=(TimeOfDay other) => compareTo(other) >= 0; + + bool isBefore(TimeOfDay other) => this < other; + bool isAfter(TimeOfDay other) => this > other; +} diff --git a/pubspec.lock b/pubspec.lock index d428ff7..b198092 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.6" + duration_picker: + dependency: "direct main" + description: + name: duration_picker + sha256: e505a749c93f3218aa4194d339e5d5480d927df23a81f075b5282511f6ac11ab + url: "https://pub.dev" + source: hosted + version: "1.2.0" easy_stepper: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f7997a5..8935e34 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: coast: ^2.0.2 collection: ^1.18.0 cupertino_icons: ^1.0.6 + duration_picker: ^1.2.0 easy_stepper: ^0.8.4 flutter: sdk: flutter