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