diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 41b686e..cb0a2a5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + element.stringValue == type) + .firstWhere((element) => element.name == type) .extractFrom(book) ?? match.group(0) ?? ''; diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index 3c8ee41..933121c 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -11,6 +11,7 @@ import 'package:vaani/features/sleep_timer/core/sleep_timer.dart'; import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart' show sleepTimerProvider; import 'package:vaani/settings/app_settings_provider.dart'; +import 'package:vaani/shared/extensions/duration_format.dart'; import 'package:vaani/shared/extensions/inverse_lerp.dart'; import 'package:vaani/shared/widgets/not_implemented.dart'; @@ -376,8 +377,8 @@ class RemainingSleepTimeDisplay extends HookConsumerWidget { ), child: Text( timer.timer == null - ? timer.duration.formatSingleLargestUnit() - : remainingTime?.formatSingleLargestUnit() ?? '', + ? timer.duration.smartBinaryFormat + : remainingTime?.smartBinaryFormat ?? '', style: Theme.of(context).textTheme.bodyMedium!.copyWith( color: Theme.of(context).colorScheme.onPrimary, ), @@ -385,17 +386,3 @@ class RemainingSleepTimeDisplay extends HookConsumerWidget { ); } } - -extension DurationFormat on Duration { - /// will return a number followed by h, m, or s depending on the duration - /// only the largest unit will be shown - String formatSingleLargestUnit() { - if (inHours > 0) { - return '${inHours}h'; - } else if (inMinutes > 0) { - return '${inMinutes}m'; - } else { - return '${inSeconds}s'; - } - } -} diff --git a/lib/features/shake_detection/core/shake_detector.dart b/lib/features/shake_detection/core/shake_detector.dart new file mode 100644 index 0000000..4b7b719 --- /dev/null +++ b/lib/features/shake_detection/core/shake_detector.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:logging/logging.dart'; +import 'package:sensors_plus/sensors_plus.dart'; +import 'package:vaani/settings/models/app_settings.dart'; + +final _logger = Logger('ShakeDetector'); + +class ShakeDetector { + final ShakeDetectionSettings _settings; + final Function()? onShakeDetected; + + ShakeDetector( + this._settings, + this.onShakeDetected, { + startImmediately = true, + }) { + _logger.fine('ShakeDetector created with settings: $_settings'); + if (startImmediately) { + start(); + } + } + + StreamSubscription? _accelerometerSubscription; + + int _currentShakeCount = 0; + + DateTime _lastShakeTime = DateTime.now(); + + void start() { + if (_accelerometerSubscription != null) { + _logger.warning('ShakeDetector is already running'); + return; + } + _accelerometerSubscription = + userAccelerometerEventStream(samplingPeriod: _settings.samplingPeriod) + .listen((event) { + if (event.rms > _settings.threshold) { + _currentShakeCount++; + + if (_currentShakeCount >= _settings.shakeTriggerCount && + !isCoolDownNeeded()) { + _logger.fine('Shake detected $_currentShakeCount times'); + + onShakeDetected?.call(); + + _lastShakeTime = DateTime.now(); + _currentShakeCount = 0; + } + } else { + _currentShakeCount = 0; + } + }); + + _logger.fine('ShakeDetector started'); + } + + void stop() { + _currentShakeCount = 0; + _accelerometerSubscription?.cancel(); + _accelerometerSubscription = null; + _logger.fine('ShakeDetector stopped'); + } + + void dispose() { + stop(); + } + + bool isCoolDownNeeded() { + return _lastShakeTime + .add(_settings.shakeTriggerCoolDown) + .isAfter(DateTime.now()); + } +} + +extension UserAccelerometerEventRMS on UserAccelerometerEvent { + double get rms => sqrt(x * x + y * y + z * z); +} diff --git a/lib/features/shake_detection/providers/shake_detector.dart b/lib/features/shake_detection/providers/shake_detector.dart new file mode 100644 index 0000000..956fc16 --- /dev/null +++ b/lib/features/shake_detection/providers/shake_detector.dart @@ -0,0 +1,138 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:logging/logging.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:vaani/features/player/providers/audiobook_player.dart' + show audiobookPlayerProvider, simpleAudiobookPlayerProvider; +import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart' + show sleepTimerProvider; +import 'package:vaani/settings/app_settings_provider.dart' + show appSettingsProvider; +import 'package:vaani/settings/models/app_settings.dart'; +import 'package:vibration/vibration.dart'; + +import '../core/shake_detector.dart' as core; + +part 'shake_detector.g.dart'; + +Logger _logger = Logger('ShakeDetector'); + +@riverpod +class ShakeDetector extends _$ShakeDetector { + @override + core.ShakeDetector? build() { + final appSettings = ref.watch(appSettingsProvider); + final shakeDetectionSettings = appSettings.shakeDetectionSettings; + + if (!shakeDetectionSettings.isEnabled) { + _logger.fine('Shake detection is disabled'); + return null; + } + final player = ref.watch(audiobookPlayerProvider); + if (!player.playing && !shakeDetectionSettings.isActiveWhenPaused) { + _logger.fine( + 'Shake detection is disabled when paused and player is not playing', + ); + return null; + } + // if sleep timer is not enabled, shake detection should not be enabled + final sleepTimer = ref.watch(sleepTimerProvider); + if (!shakeDetectionSettings.isPlaybackManagementEnabled && + sleepTimer == null) { + _logger.fine('No playback management is enabled and sleep timer is off, ' + 'so shake detection is disabled'); + return null; + } + + _logger.fine('Creating shake detector'); + final detector = core.ShakeDetector( + shakeDetectionSettings, + () { + doShakeAction( + shakeDetectionSettings.shakeAction, + ref: ref, + ); + shakeDetectionSettings.feedback.forEach(postShakeFeedback); + }, + ); + ref.onDispose(detector.dispose); + return detector; + } + + void doShakeAction( + ShakeAction shakeAction, { + required Ref ref, + }) { + final player = ref.watch(simpleAudiobookPlayerProvider); + switch (shakeAction) { + case ShakeAction.resetSleepTimer: + _logger.fine('Resetting sleep timer'); + ref.read(sleepTimerProvider.notifier).restartTimer(); + break; + case ShakeAction.fastForward: + _logger.fine('Fast forwarding'); + player.seek(player.position + const Duration(seconds: 30)); + break; + case ShakeAction.rewind: + _logger.fine('Rewinding'); + player.seek(player.position - const Duration(seconds: 30)); + break; + case ShakeAction.playPause: + if (player.book == null) { + _logger.warning('No book is loaded'); + break; + } + player.togglePlayPause(); + break; + default: + break; + } + } + + Future postShakeFeedback(ShakeDetectedFeedback feedback) async { + switch (feedback) { + case ShakeDetectedFeedback.vibrate: + _logger.fine('Vibrating'); + + if (await Vibration.hasAmplitudeControl() ?? false) { + Vibration.vibrate(amplitude: 128, duration: 200); + break; + } + + if (await Vibration.hasVibrator() ?? false) { + Vibration.vibrate(); + break; + } + + _logger.warning('No vibration support'); + + break; + case ShakeDetectedFeedback.beep: + _logger.fine('Beeping'); + final player = AudioPlayer(); + await player.setAsset('assets/sounds/beep.mp3'); + await player.setVolume(0.5); + await player.play(); + await player.dispose(); + break; + default: + break; + } + } +} + +extension on ShakeDetectionSettings { + bool get isActiveWhenPaused { + // If the shake action is play/pause, it should be required when not playing + return shakeAction == ShakeAction.playPause; + } + + bool get isPlaybackManagementEnabled { + return {ShakeAction.playPause, ShakeAction.fastForward, ShakeAction.rewind} + .contains(shakeAction); + } + + bool get shouldActOnSleepTimer { + return {ShakeAction.resetSleepTimer}.contains(shakeAction); + } +} diff --git a/lib/features/shake_detection/providers/shake_detector.g.dart b/lib/features/shake_detection/providers/shake_detector.g.dart new file mode 100644 index 0000000..c3035b8 --- /dev/null +++ b/lib/features/shake_detection/providers/shake_detector.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shake_detector.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$shakeDetectorHash() => r'7bfbdd22f2f43ef3e3858d226d1eb78923e8114d'; + +/// See also [ShakeDetector]. +@ProviderFor(ShakeDetector) +final shakeDetectorProvider = + AutoDisposeNotifierProvider.internal( + ShakeDetector.new, + name: r'shakeDetectorProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$shakeDetectorHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ShakeDetector = AutoDisposeNotifier; +// 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/sleep_timer/core/sleep_timer.dart b/lib/features/sleep_timer/core/sleep_timer.dart index fb38e0f..728cec1 100644 --- a/lib/features/sleep_timer/core/sleep_timer.dart +++ b/lib/features/sleep_timer/core/sleep_timer.dart @@ -19,7 +19,7 @@ class SleepTimer { set duration(Duration value) { _duration = value; - reset(); + clearCountDownTimer(); } /// The player to be paused @@ -40,7 +40,7 @@ class SleepTimer { player.playbackEventStream.listen((event) { if (event.processingState == ProcessingState.completed || event.processingState == ProcessingState.idle) { - reset(); + clearCountDownTimer(); } }), ); @@ -49,9 +49,9 @@ class SleepTimer { _subscriptions.add( player.playerStateStream.listen((state) { if (state.playing && timer == null) { - startTimer(); + startCountDown(); } else if (!state.playing) { - reset(); + clearCountDownTimer(); } }), ); @@ -59,8 +59,8 @@ class SleepTimer { _logger.fine('created with duration: $duration'); } - /// resets the timer - void reset() { + /// resets the timer and stops it + void clearCountDownTimer() { if (timer != null) { timer!.cancel(); _logger.fine( @@ -70,15 +70,25 @@ class SleepTimer { } } + /// refills the timer with the default duration and starts it if the player is playing + /// if the player is not playing, the timer is stopped + void restartTimer() { + clearCountDownTimer(); + if (player.playing) { + startCountDown(); + } + _logger.fine('restarted timer'); + } + /// starts the timer with the given duration or the default duration - void startTimer([ + void startCountDown([ Duration? forDuration, ]) { - reset(); + clearCountDownTimer(); duration = forDuration ?? duration; timer = Timer(duration, () { player.pause(); - reset(); + clearCountDownTimer(); _logger.fine('paused player after $duration'); }); startedAt = DateTime.now(); @@ -103,7 +113,7 @@ class SleepTimer { /// dispose the timer void dispose() { - reset(); + clearCountDownTimer(); for (var sub in _subscriptions) { sub.cancel(); } diff --git a/lib/features/sleep_timer/providers/sleep_timer_provider.dart b/lib/features/sleep_timer/providers/sleep_timer_provider.dart index 563c34e..6f1b1b7 100644 --- a/lib/features/sleep_timer/providers/sleep_timer_provider.dart +++ b/lib/features/sleep_timer/providers/sleep_timer_provider.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; -import 'package:vaani/features/sleep_timer/core/sleep_timer.dart' - as core; +import 'package:vaani/features/sleep_timer/core/sleep_timer.dart' as core; import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/shared/extensions/time_of_day.dart'; @@ -48,9 +47,16 @@ class SleepTimer extends _$SleepTimer { ); ref.onDispose(timer.dispose); state = timer; + state!.startCountDown(); } } + void restartTimer() { + state?.restartTimer(); + + ref.notifyListeners(); + } + 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 ef930bf..fe74010 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'ad77e82c1b513bbc62815c64ce1ed403f92fc055'; +String _$sleepTimerHash() => r'9d9f20267da91e5483151b58b7d4d7c0762c3ca7'; /// See also [SleepTimer]. @ProviderFor(SleepTimer) diff --git a/lib/main.dart b/lib/main.dart index cea6f7f..3228a8a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:vaani/features/downloads/providers/download_manager.dart'; import 'package:vaani/features/playback_reporting/providers/playback_reporter_provider.dart'; import 'package:vaani/features/player/core/init.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; +import 'package:vaani/features/shake_detection/providers/shake_detector.dart'; import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; @@ -93,6 +94,7 @@ class _EagerInitialization extends ConsumerWidget { ref.watch(sleepTimerProvider); ref.watch(playbackReporterProvider); ref.watch(simpleDownloadManagerProvider); + ref.watch(shakeDetectorProvider); } catch (e) { debugPrintStack(stackTrace: StackTrace.current, label: e.toString()); } diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 71bb446..4d8e7c1 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -42,6 +42,11 @@ class Routes { name: 'playerSettings', parentRoute: settings, ); + static const shakeDetectorSettings = _SimpleRoute( + pathName: 'shakeDetector', + name: 'shakeDetectorSettings', + parentRoute: settings, + ); // search and explore static const search = _SimpleRoute( diff --git a/lib/router/router.dart b/lib/router/router.dart index edf9433..8910fc3 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -14,6 +14,7 @@ import 'package:vaani/settings/view/app_settings_page.dart'; import 'package:vaani/settings/view/auto_sleep_timer_settings_page.dart'; import 'package:vaani/settings/view/notification_settings_page.dart'; import 'package:vaani/settings/view/player_settings_page.dart'; +import 'package:vaani/settings/view/shake_detector_settings_page.dart'; import 'scaffold_with_nav_bar.dart'; import 'transitions/slide.dart'; @@ -195,6 +196,13 @@ class MyAppRouter { pageBuilder: defaultPageBuilder(const PlayerSettingsPage()), ), + GoRoute( + path: Routes.shakeDetectorSettings.pathName, + name: Routes.shakeDetectorSettings.name, + pageBuilder: defaultPageBuilder( + const ShakeDetectorSettingsPage(), + ), + ), ], ), GoRoute( diff --git a/lib/settings/models/app_settings.dart b/lib/settings/models/app_settings.dart index 4b70f64..09a07e2 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -16,6 +16,8 @@ class AppSettings with _$AppSettings { @Default(PlayerSettings()) PlayerSettings playerSettings, @Default(DownloadSettings()) DownloadSettings downloadSettings, @Default(NotificationSettings()) NotificationSettings notificationSettings, + @Default(ShakeDetectionSettings()) + ShakeDetectionSettings shakeDetectionSettings, }) = _AppSettings; factory AppSettings.fromJson(Map json) => @@ -165,17 +167,13 @@ class NotificationSettings with _$NotificationSettings { } enum NotificationTitleType { - chapterTitle('chapterTitle'), - bookTitle('bookTitle'), - author('author'), - subtitle('subtitle'), - series('series'), - narrator('narrator'), - year('year'); - - const NotificationTitleType(this.stringValue); - - final String stringValue; + chapterTitle, + bookTitle, + author, + subtitle, + series, + narrator, + year, } enum NotificationMediaControl { @@ -190,3 +188,41 @@ enum NotificationMediaControl { final IconData icon; } + +/// Shake Detection Settings +@freezed +class ShakeDetectionSettings with _$ShakeDetectionSettings { + const factory ShakeDetectionSettings({ + @Default(true) bool isEnabled, + @Default(ShakeDirection.horizontal) ShakeDirection direction, + @Default(5) double threshold, + @Default(ShakeAction.resetSleepTimer) ShakeAction shakeAction, + @Default({ShakeDetectedFeedback.vibrate, ShakeDetectedFeedback.beep}) + Set feedback, + @Default(0.5) double beepVolume, + + /// the duration to wait before the shake detection is enabled again + @Default(Duration(seconds: 5)) Duration shakeTriggerCoolDown, + + /// the number of shakes required to trigger the action + @Default(2) int shakeTriggerCount, + + /// acceleration sampling interval + @Default(Duration(milliseconds: 100)) Duration samplingPeriod, + }) = _ShakeDetectionSettings; + + factory ShakeDetectionSettings.fromJson(Map json) => + _$ShakeDetectionSettingsFromJson(json); +} + +enum ShakeDirection { horizontal, vertical } + +enum ShakeAction { + none, + playPause, + resetSleepTimer, + fastForward, + rewind, +} + +enum ShakeDetectedFeedback { vibrate, beep } diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index 415a60a..3c1d9f4 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -25,6 +25,8 @@ mixin _$AppSettings { DownloadSettings get downloadSettings => throw _privateConstructorUsedError; NotificationSettings get notificationSettings => throw _privateConstructorUsedError; + ShakeDetectionSettings get shakeDetectionSettings => + throw _privateConstructorUsedError; /// Serializes this AppSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -46,12 +48,14 @@ abstract class $AppSettingsCopyWith<$Res> { {ThemeSettings themeSettings, PlayerSettings playerSettings, DownloadSettings downloadSettings, - NotificationSettings notificationSettings}); + NotificationSettings notificationSettings, + ShakeDetectionSettings shakeDetectionSettings}); $ThemeSettingsCopyWith<$Res> get themeSettings; $PlayerSettingsCopyWith<$Res> get playerSettings; $DownloadSettingsCopyWith<$Res> get downloadSettings; $NotificationSettingsCopyWith<$Res> get notificationSettings; + $ShakeDetectionSettingsCopyWith<$Res> get shakeDetectionSettings; } /// @nodoc @@ -73,6 +77,7 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> Object? playerSettings = null, Object? downloadSettings = null, Object? notificationSettings = null, + Object? shakeDetectionSettings = null, }) { return _then(_value.copyWith( themeSettings: null == themeSettings @@ -91,6 +96,10 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> ? _value.notificationSettings : notificationSettings // ignore: cast_nullable_to_non_nullable as NotificationSettings, + shakeDetectionSettings: null == shakeDetectionSettings + ? _value.shakeDetectionSettings + : shakeDetectionSettings // ignore: cast_nullable_to_non_nullable + as ShakeDetectionSettings, ) as $Val); } @@ -134,6 +143,17 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> return _then(_value.copyWith(notificationSettings: value) as $Val); }); } + + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ShakeDetectionSettingsCopyWith<$Res> get shakeDetectionSettings { + return $ShakeDetectionSettingsCopyWith<$Res>(_value.shakeDetectionSettings, + (value) { + return _then(_value.copyWith(shakeDetectionSettings: value) as $Val); + }); + } } /// @nodoc @@ -148,7 +168,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res> {ThemeSettings themeSettings, PlayerSettings playerSettings, DownloadSettings downloadSettings, - NotificationSettings notificationSettings}); + NotificationSettings notificationSettings, + ShakeDetectionSettings shakeDetectionSettings}); @override $ThemeSettingsCopyWith<$Res> get themeSettings; @@ -158,6 +179,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res> $DownloadSettingsCopyWith<$Res> get downloadSettings; @override $NotificationSettingsCopyWith<$Res> get notificationSettings; + @override + $ShakeDetectionSettingsCopyWith<$Res> get shakeDetectionSettings; } /// @nodoc @@ -177,6 +200,7 @@ class __$$AppSettingsImplCopyWithImpl<$Res> Object? playerSettings = null, Object? downloadSettings = null, Object? notificationSettings = null, + Object? shakeDetectionSettings = null, }) { return _then(_$AppSettingsImpl( themeSettings: null == themeSettings @@ -195,6 +219,10 @@ class __$$AppSettingsImplCopyWithImpl<$Res> ? _value.notificationSettings : notificationSettings // ignore: cast_nullable_to_non_nullable as NotificationSettings, + shakeDetectionSettings: null == shakeDetectionSettings + ? _value.shakeDetectionSettings + : shakeDetectionSettings // ignore: cast_nullable_to_non_nullable + as ShakeDetectionSettings, )); } } @@ -206,7 +234,8 @@ class _$AppSettingsImpl implements _AppSettings { {this.themeSettings = const ThemeSettings(), this.playerSettings = const PlayerSettings(), this.downloadSettings = const DownloadSettings(), - this.notificationSettings = const NotificationSettings()}); + this.notificationSettings = const NotificationSettings(), + this.shakeDetectionSettings = const ShakeDetectionSettings()}); factory _$AppSettingsImpl.fromJson(Map json) => _$$AppSettingsImplFromJson(json); @@ -223,10 +252,13 @@ class _$AppSettingsImpl implements _AppSettings { @override @JsonKey() final NotificationSettings notificationSettings; + @override + @JsonKey() + final ShakeDetectionSettings shakeDetectionSettings; @override String toString() { - return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings, notificationSettings: $notificationSettings)'; + return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings, notificationSettings: $notificationSettings, shakeDetectionSettings: $shakeDetectionSettings)'; } @override @@ -241,13 +273,15 @@ class _$AppSettingsImpl implements _AppSettings { (identical(other.downloadSettings, downloadSettings) || other.downloadSettings == downloadSettings) && (identical(other.notificationSettings, notificationSettings) || - other.notificationSettings == notificationSettings)); + other.notificationSettings == notificationSettings) && + (identical(other.shakeDetectionSettings, shakeDetectionSettings) || + other.shakeDetectionSettings == shakeDetectionSettings)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, themeSettings, playerSettings, - downloadSettings, notificationSettings); + downloadSettings, notificationSettings, shakeDetectionSettings); /// Create a copy of AppSettings /// with the given fields replaced by the non-null parameter values. @@ -270,7 +304,8 @@ abstract class _AppSettings implements AppSettings { {final ThemeSettings themeSettings, final PlayerSettings playerSettings, final DownloadSettings downloadSettings, - final NotificationSettings notificationSettings}) = _$AppSettingsImpl; + final NotificationSettings notificationSettings, + final ShakeDetectionSettings shakeDetectionSettings}) = _$AppSettingsImpl; factory _AppSettings.fromJson(Map json) = _$AppSettingsImpl.fromJson; @@ -283,6 +318,8 @@ abstract class _AppSettings implements AppSettings { DownloadSettings get downloadSettings; @override NotificationSettings get notificationSettings; + @override + ShakeDetectionSettings get shakeDetectionSettings; /// Create a copy of AppSettings /// with the given fields replaced by the non-null parameter values. @@ -2381,3 +2418,377 @@ abstract class _NotificationSettings implements NotificationSettings { _$$NotificationSettingsImplCopyWith<_$NotificationSettingsImpl> get copyWith => throw _privateConstructorUsedError; } + +ShakeDetectionSettings _$ShakeDetectionSettingsFromJson( + Map json) { + return _ShakeDetectionSettings.fromJson(json); +} + +/// @nodoc +mixin _$ShakeDetectionSettings { + bool get isEnabled => throw _privateConstructorUsedError; + ShakeDirection get direction => throw _privateConstructorUsedError; + double get threshold => throw _privateConstructorUsedError; + ShakeAction get shakeAction => throw _privateConstructorUsedError; + Set get feedback => throw _privateConstructorUsedError; + double get beepVolume => throw _privateConstructorUsedError; + + /// the duration to wait before the shake detection is enabled again + Duration get shakeTriggerCoolDown => throw _privateConstructorUsedError; + + /// the number of shakes required to trigger the action + int get shakeTriggerCount => throw _privateConstructorUsedError; + + /// acceleration sampling interval + Duration get samplingPeriod => throw _privateConstructorUsedError; + + /// Serializes this ShakeDetectionSettings to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ShakeDetectionSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ShakeDetectionSettingsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ShakeDetectionSettingsCopyWith<$Res> { + factory $ShakeDetectionSettingsCopyWith(ShakeDetectionSettings value, + $Res Function(ShakeDetectionSettings) then) = + _$ShakeDetectionSettingsCopyWithImpl<$Res, ShakeDetectionSettings>; + @useResult + $Res call( + {bool isEnabled, + ShakeDirection direction, + double threshold, + ShakeAction shakeAction, + Set feedback, + double beepVolume, + Duration shakeTriggerCoolDown, + int shakeTriggerCount, + Duration samplingPeriod}); +} + +/// @nodoc +class _$ShakeDetectionSettingsCopyWithImpl<$Res, + $Val extends ShakeDetectionSettings> + implements $ShakeDetectionSettingsCopyWith<$Res> { + _$ShakeDetectionSettingsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ShakeDetectionSettings + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isEnabled = null, + Object? direction = null, + Object? threshold = null, + Object? shakeAction = null, + Object? feedback = null, + Object? beepVolume = null, + Object? shakeTriggerCoolDown = null, + Object? shakeTriggerCount = null, + Object? samplingPeriod = null, + }) { + return _then(_value.copyWith( + isEnabled: null == isEnabled + ? _value.isEnabled + : isEnabled // ignore: cast_nullable_to_non_nullable + as bool, + direction: null == direction + ? _value.direction + : direction // ignore: cast_nullable_to_non_nullable + as ShakeDirection, + threshold: null == threshold + ? _value.threshold + : threshold // ignore: cast_nullable_to_non_nullable + as double, + shakeAction: null == shakeAction + ? _value.shakeAction + : shakeAction // ignore: cast_nullable_to_non_nullable + as ShakeAction, + feedback: null == feedback + ? _value.feedback + : feedback // ignore: cast_nullable_to_non_nullable + as Set, + beepVolume: null == beepVolume + ? _value.beepVolume + : beepVolume // ignore: cast_nullable_to_non_nullable + as double, + shakeTriggerCoolDown: null == shakeTriggerCoolDown + ? _value.shakeTriggerCoolDown + : shakeTriggerCoolDown // ignore: cast_nullable_to_non_nullable + as Duration, + shakeTriggerCount: null == shakeTriggerCount + ? _value.shakeTriggerCount + : shakeTriggerCount // ignore: cast_nullable_to_non_nullable + as int, + samplingPeriod: null == samplingPeriod + ? _value.samplingPeriod + : samplingPeriod // ignore: cast_nullable_to_non_nullable + as Duration, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ShakeDetectionSettingsImplCopyWith<$Res> + implements $ShakeDetectionSettingsCopyWith<$Res> { + factory _$$ShakeDetectionSettingsImplCopyWith( + _$ShakeDetectionSettingsImpl value, + $Res Function(_$ShakeDetectionSettingsImpl) then) = + __$$ShakeDetectionSettingsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool isEnabled, + ShakeDirection direction, + double threshold, + ShakeAction shakeAction, + Set feedback, + double beepVolume, + Duration shakeTriggerCoolDown, + int shakeTriggerCount, + Duration samplingPeriod}); +} + +/// @nodoc +class __$$ShakeDetectionSettingsImplCopyWithImpl<$Res> + extends _$ShakeDetectionSettingsCopyWithImpl<$Res, + _$ShakeDetectionSettingsImpl> + implements _$$ShakeDetectionSettingsImplCopyWith<$Res> { + __$$ShakeDetectionSettingsImplCopyWithImpl( + _$ShakeDetectionSettingsImpl _value, + $Res Function(_$ShakeDetectionSettingsImpl) _then) + : super(_value, _then); + + /// Create a copy of ShakeDetectionSettings + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isEnabled = null, + Object? direction = null, + Object? threshold = null, + Object? shakeAction = null, + Object? feedback = null, + Object? beepVolume = null, + Object? shakeTriggerCoolDown = null, + Object? shakeTriggerCount = null, + Object? samplingPeriod = null, + }) { + return _then(_$ShakeDetectionSettingsImpl( + isEnabled: null == isEnabled + ? _value.isEnabled + : isEnabled // ignore: cast_nullable_to_non_nullable + as bool, + direction: null == direction + ? _value.direction + : direction // ignore: cast_nullable_to_non_nullable + as ShakeDirection, + threshold: null == threshold + ? _value.threshold + : threshold // ignore: cast_nullable_to_non_nullable + as double, + shakeAction: null == shakeAction + ? _value.shakeAction + : shakeAction // ignore: cast_nullable_to_non_nullable + as ShakeAction, + feedback: null == feedback + ? _value._feedback + : feedback // ignore: cast_nullable_to_non_nullable + as Set, + beepVolume: null == beepVolume + ? _value.beepVolume + : beepVolume // ignore: cast_nullable_to_non_nullable + as double, + shakeTriggerCoolDown: null == shakeTriggerCoolDown + ? _value.shakeTriggerCoolDown + : shakeTriggerCoolDown // ignore: cast_nullable_to_non_nullable + as Duration, + shakeTriggerCount: null == shakeTriggerCount + ? _value.shakeTriggerCount + : shakeTriggerCount // ignore: cast_nullable_to_non_nullable + as int, + samplingPeriod: null == samplingPeriod + ? _value.samplingPeriod + : samplingPeriod // ignore: cast_nullable_to_non_nullable + as Duration, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ShakeDetectionSettingsImpl implements _ShakeDetectionSettings { + const _$ShakeDetectionSettingsImpl( + {this.isEnabled = true, + this.direction = ShakeDirection.horizontal, + this.threshold = 5, + this.shakeAction = ShakeAction.resetSleepTimer, + final Set feedback = const { + ShakeDetectedFeedback.vibrate, + ShakeDetectedFeedback.beep + }, + this.beepVolume = 0.5, + this.shakeTriggerCoolDown = const Duration(seconds: 5), + this.shakeTriggerCount = 2, + this.samplingPeriod = const Duration(milliseconds: 100)}) + : _feedback = feedback; + + factory _$ShakeDetectionSettingsImpl.fromJson(Map json) => + _$$ShakeDetectionSettingsImplFromJson(json); + + @override + @JsonKey() + final bool isEnabled; + @override + @JsonKey() + final ShakeDirection direction; + @override + @JsonKey() + final double threshold; + @override + @JsonKey() + final ShakeAction shakeAction; + final Set _feedback; + @override + @JsonKey() + Set get feedback { + if (_feedback is EqualUnmodifiableSetView) return _feedback; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_feedback); + } + + @override + @JsonKey() + final double beepVolume; + + /// the duration to wait before the shake detection is enabled again + @override + @JsonKey() + final Duration shakeTriggerCoolDown; + + /// the number of shakes required to trigger the action + @override + @JsonKey() + final int shakeTriggerCount; + + /// acceleration sampling interval + @override + @JsonKey() + final Duration samplingPeriod; + + @override + String toString() { + return 'ShakeDetectionSettings(isEnabled: $isEnabled, direction: $direction, threshold: $threshold, shakeAction: $shakeAction, feedback: $feedback, beepVolume: $beepVolume, shakeTriggerCoolDown: $shakeTriggerCoolDown, shakeTriggerCount: $shakeTriggerCount, samplingPeriod: $samplingPeriod)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ShakeDetectionSettingsImpl && + (identical(other.isEnabled, isEnabled) || + other.isEnabled == isEnabled) && + (identical(other.direction, direction) || + other.direction == direction) && + (identical(other.threshold, threshold) || + other.threshold == threshold) && + (identical(other.shakeAction, shakeAction) || + other.shakeAction == shakeAction) && + const DeepCollectionEquality().equals(other._feedback, _feedback) && + (identical(other.beepVolume, beepVolume) || + other.beepVolume == beepVolume) && + (identical(other.shakeTriggerCoolDown, shakeTriggerCoolDown) || + other.shakeTriggerCoolDown == shakeTriggerCoolDown) && + (identical(other.shakeTriggerCount, shakeTriggerCount) || + other.shakeTriggerCount == shakeTriggerCount) && + (identical(other.samplingPeriod, samplingPeriod) || + other.samplingPeriod == samplingPeriod)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + isEnabled, + direction, + threshold, + shakeAction, + const DeepCollectionEquality().hash(_feedback), + beepVolume, + shakeTriggerCoolDown, + shakeTriggerCount, + samplingPeriod); + + /// Create a copy of ShakeDetectionSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ShakeDetectionSettingsImplCopyWith<_$ShakeDetectionSettingsImpl> + get copyWith => __$$ShakeDetectionSettingsImplCopyWithImpl< + _$ShakeDetectionSettingsImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ShakeDetectionSettingsImplToJson( + this, + ); + } +} + +abstract class _ShakeDetectionSettings implements ShakeDetectionSettings { + const factory _ShakeDetectionSettings( + {final bool isEnabled, + final ShakeDirection direction, + final double threshold, + final ShakeAction shakeAction, + final Set feedback, + final double beepVolume, + final Duration shakeTriggerCoolDown, + final int shakeTriggerCount, + final Duration samplingPeriod}) = _$ShakeDetectionSettingsImpl; + + factory _ShakeDetectionSettings.fromJson(Map json) = + _$ShakeDetectionSettingsImpl.fromJson; + + @override + bool get isEnabled; + @override + ShakeDirection get direction; + @override + double get threshold; + @override + ShakeAction get shakeAction; + @override + Set get feedback; + @override + double get beepVolume; + + /// the duration to wait before the shake detection is enabled again + @override + Duration get shakeTriggerCoolDown; + + /// the number of shakes required to trigger the action + @override + int get shakeTriggerCount; + + /// acceleration sampling interval + @override + Duration get samplingPeriod; + + /// Create a copy of ShakeDetectionSettings + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ShakeDetectionSettingsImplCopyWith<_$ShakeDetectionSettingsImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index c01610a..3b0784f 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -24,6 +24,10 @@ _$AppSettingsImpl _$$AppSettingsImplFromJson(Map json) => ? const NotificationSettings() : NotificationSettings.fromJson( json['notificationSettings'] as Map), + shakeDetectionSettings: json['shakeDetectionSettings'] == null + ? const ShakeDetectionSettings() + : ShakeDetectionSettings.fromJson( + json['shakeDetectionSettings'] as Map), ); Map _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => @@ -32,6 +36,7 @@ Map _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => 'playerSettings': instance.playerSettings, 'downloadSettings': instance.downloadSettings, 'notificationSettings': instance.notificationSettings, + 'shakeDetectionSettings': instance.shakeDetectionSettings, }; _$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map json) => @@ -274,3 +279,63 @@ const _$NotificationMediaControlEnumMap = { NotificationMediaControl.skipToNextChapter: 'skipToNextChapter', NotificationMediaControl.skipToPreviousChapter: 'skipToPreviousChapter', }; + +_$ShakeDetectionSettingsImpl _$$ShakeDetectionSettingsImplFromJson( + Map json) => + _$ShakeDetectionSettingsImpl( + isEnabled: json['isEnabled'] as bool? ?? true, + direction: + $enumDecodeNullable(_$ShakeDirectionEnumMap, json['direction']) ?? + ShakeDirection.horizontal, + threshold: (json['threshold'] as num?)?.toDouble() ?? 5, + shakeAction: + $enumDecodeNullable(_$ShakeActionEnumMap, json['shakeAction']) ?? + ShakeAction.resetSleepTimer, + feedback: (json['feedback'] as List?) + ?.map((e) => $enumDecode(_$ShakeDetectedFeedbackEnumMap, e)) + .toSet() ?? + const {ShakeDetectedFeedback.vibrate, ShakeDetectedFeedback.beep}, + beepVolume: (json['beepVolume'] as num?)?.toDouble() ?? 0.5, + shakeTriggerCoolDown: json['shakeTriggerCoolDown'] == null + ? const Duration(seconds: 5) + : Duration( + microseconds: (json['shakeTriggerCoolDown'] as num).toInt()), + shakeTriggerCount: (json['shakeTriggerCount'] as num?)?.toInt() ?? 2, + samplingPeriod: json['samplingPeriod'] == null + ? const Duration(milliseconds: 100) + : Duration(microseconds: (json['samplingPeriod'] as num).toInt()), + ); + +Map _$$ShakeDetectionSettingsImplToJson( + _$ShakeDetectionSettingsImpl instance) => + { + 'isEnabled': instance.isEnabled, + 'direction': _$ShakeDirectionEnumMap[instance.direction]!, + 'threshold': instance.threshold, + 'shakeAction': _$ShakeActionEnumMap[instance.shakeAction]!, + 'feedback': instance.feedback + .map((e) => _$ShakeDetectedFeedbackEnumMap[e]!) + .toList(), + 'beepVolume': instance.beepVolume, + 'shakeTriggerCoolDown': instance.shakeTriggerCoolDown.inMicroseconds, + 'shakeTriggerCount': instance.shakeTriggerCount, + 'samplingPeriod': instance.samplingPeriod.inMicroseconds, + }; + +const _$ShakeDirectionEnumMap = { + ShakeDirection.horizontal: 'horizontal', + ShakeDirection.vertical: 'vertical', +}; + +const _$ShakeActionEnumMap = { + ShakeAction.none: 'none', + ShakeAction.playPause: 'playPause', + ShakeAction.resetSleepTimer: 'resetSleepTimer', + ShakeAction.fastForward: 'fastForward', + ShakeAction.rewind: 'rewind', +}; + +const _$ShakeDetectedFeedbackEnumMap = { + ShakeDetectedFeedback.vibrate: 'vibrate', + ShakeDetectedFeedback.beep: 'beep', +}; diff --git a/lib/settings/view/app_settings_page.dart b/lib/settings/view/app_settings_page.dart index bbdf1a3..58754c6 100644 --- a/lib/settings/view/app_settings_page.dart +++ b/lib/settings/view/app_settings_page.dart @@ -12,6 +12,7 @@ import 'package:vaani/router/router.dart'; import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/settings/models/app_settings.dart' as model; import 'package:vaani/settings/view/simple_settings_page.dart'; +import 'package:vaani/settings/view/widgets/navigation_with_switch_tile.dart'; class AppSettingsPage extends HookConsumerWidget { const AppSettingsPage({ @@ -30,39 +31,6 @@ class AppSettingsPage extends HookConsumerWidget { return SimpleSettingsPage( title: const Text('App Settings'), sections: [ - // General section - SettingsSection( - margin: const EdgeInsetsDirectional.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - title: Text( - 'General', - style: Theme.of(context).textTheme.titleLarge, - ), - tiles: [ - SettingsTile( - title: const Text('Notification Media Player'), - leading: const Icon(Icons.play_lesson), - description: const Text( - 'Customize the media player in notifications', - ), - onPressed: (context) { - context.pushNamed(Routes.notificationSettings.name); - }, - ), - SettingsTile( - title: const Text('Player Settings'), - leading: const Icon(Icons.play_arrow), - description: const Text( - 'Customize the player settings', - ), - onPressed: (context) { - context.pushNamed(Routes.playerSettings.name); - }, - ), - ], - ), // Appearance section SettingsSection( margin: const EdgeInsetsDirectional.symmetric( @@ -106,20 +74,19 @@ class AppSettingsPage extends HookConsumerWidget { ], ), - // Sleep Timer section + // General section SettingsSection( margin: const EdgeInsetsDirectional.symmetric( horizontal: 16.0, vertical: 8.0, ), title: Text( - 'Sleep Timer', + 'General', style: Theme.of(context).textTheme.titleLarge, ), tiles: [ - SettingsTile.navigation( - // initialValue: sleepTimerSettings.autoTurnOnTimer, - title: const Text('Auto Turn On Timer'), + NavigationWithSwitchTile( + title: const Text('Auto Turn On Sleep Timer'), description: const Text( 'Automatically turn on the sleep timer based on the time of day', ), @@ -127,39 +94,57 @@ class AppSettingsPage extends HookConsumerWidget { ? 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).update( - appSettings.copyWith.playerSettings - .sleepTimerSettings( - autoTurnOnTimer: value, - ), - ); - }, - ), - ], - ), + value: sleepTimerSettings.autoTurnOnTimer, + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings.sleepTimerSettings( + autoTurnOnTimer: value, + ), + ); + }, + ), + SettingsTile( + title: const Text('Notification Media Player'), + leading: const Icon(Icons.play_lesson), + description: const Text( + 'Customize the media player in notifications', ), + onPressed: (context) { + context.pushNamed(Routes.notificationSettings.name); + }, + ), + SettingsTile( + title: const Text('Player Settings'), + leading: const Icon(Icons.play_arrow), + description: const Text( + 'Customize the player settings', + ), + onPressed: (context) { + context.pushNamed(Routes.playerSettings.name); + }, + ), + NavigationWithSwitchTile( + title: const Text('Shake Detector'), + leading: const Icon(Icons.vibration), + description: const Text( + 'Customize the shake detector settings', + ), + value: appSettings.shakeDetectionSettings.isEnabled, + onPressed: (context) { + context.pushNamed(Routes.shakeDetectorSettings.name); + }, + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.shakeDetectionSettings( + isEnabled: value, + ), + ); + }, ), ], ), - // Backup and Restore section SettingsSection( margin: const EdgeInsetsDirectional.symmetric( diff --git a/lib/settings/view/notification_settings_page.dart b/lib/settings/view/notification_settings_page.dart index b673add..140d21c 100644 --- a/lib/settings/view/notification_settings_page.dart +++ b/lib/settings/view/notification_settings_page.dart @@ -6,6 +6,7 @@ import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/settings/models/app_settings.dart'; import 'package:vaani/settings/view/buttons.dart'; import 'package:vaani/settings/view/simple_settings_page.dart'; +import 'package:vaani/shared/extensions/enum.dart'; class NotificationSettingsPage extends HookConsumerWidget { const NotificationSettingsPage({ @@ -16,7 +17,7 @@ class NotificationSettingsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final appSettings = ref.watch(appSettingsProvider); final notificationSettings = appSettings.notificationSettings; - + final primaryColor = Theme.of(context).colorScheme.primary; return SimpleSettingsPage( title: const Text('Notification Settings'), sections: [ @@ -37,7 +38,10 @@ class NotificationSettingsPage extends HookConsumerWidget { children: [ TextSpan( text: notificationSettings.primaryTitle, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: primaryColor, + ), ), ], ), @@ -72,7 +76,10 @@ class NotificationSettingsPage extends HookConsumerWidget { children: [ TextSpan( text: notificationSettings.secondaryTitle, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: primaryColor, + ), ), ], ), @@ -151,19 +158,17 @@ class NotificationSettingsPage extends HookConsumerWidget { title: const Text('Media Controls'), leading: const Icon(Icons.control_camera), // description: const Text('Select the media controls to display'), - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Select the media controls to display'), - Wrap( - spacing: 8.0, - children: notificationSettings.mediaControls - .map( - (control) => Icon(control.icon), - ) - .toList(), - ), - ], + description: const Text('Select the media controls to display'), + trailing: Wrap( + spacing: 8.0, + children: notificationSettings.mediaControls + .map( + (control) => Icon( + control.icon, + color: primaryColor, + ), + ) + .toList(), ), onPressed: (context) async { final selectedControls = @@ -225,7 +230,7 @@ class MediaControlsPicker extends HookConsumerWidget { OkButton( onPressed: () { Navigator.of(context).pop(selectedMediaControls.value); - } + }, ), ], // a list of chips to easily select the media controls to display @@ -235,14 +240,8 @@ class MediaControlsPicker extends HookConsumerWidget { children: NotificationMediaControl.values .map( (control) => ChoiceChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(control.icon), - const SizedBox(width: 4.0), - Text(control.name), - ], - ), + avatar: Icon(control.icon), + label: Text(control.pascalCase), selected: selectedMediaControls.value.contains(control), onSelected: (selected) { if (selected) { @@ -332,7 +331,7 @@ class NotificationTitlePicker extends HookConsumerWidget { OkButton( onPressed: () { Navigator.of(context).pop(selectedTitle.value); - } + }, ), ], // a list of chips to easily insert available fields into the text field @@ -362,10 +361,10 @@ class NotificationTitlePicker extends HookConsumerWidget { children: NotificationTitleType.values .map( (type) => ActionChip( - label: Text(type.stringValue), + label: Text(type.pascalCase), onPressed: () { final text = controller.text; - final newText = '$text\$${type.stringValue}'; + final newText = '$text\$${type.name}'; controller.text = newText; selectedTitle.value = newText; }, @@ -378,16 +377,3 @@ class NotificationTitlePicker extends HookConsumerWidget { ); } } - -Future showNotificationTitlePicker( - BuildContext context, { - required String initialValue, - required String title, -}) async { - return showDialog( - context: context, - builder: (context) { - return NotificationTitlePicker(initialValue: initialValue, title: title); - }, - ); -} diff --git a/lib/settings/view/player_settings_page.dart b/lib/settings/view/player_settings_page.dart index c07f8cf..58b050f 100644 --- a/lib/settings/view/player_settings_page.dart +++ b/lib/settings/view/player_settings_page.dart @@ -17,6 +17,7 @@ class PlayerSettingsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final appSettings = ref.watch(appSettingsProvider); final playerSettings = appSettings.playerSettings; + final primaryColor = Theme.of(context).colorScheme.primary; return SimpleSettingsPage( title: const Text('Player Settings'), @@ -47,7 +48,11 @@ class PlayerSettingsPage extends HookConsumerWidget { // preferred default speed SettingsTile( title: const Text('Default Speed'), - description: Text('${playerSettings.preferredDefaultSpeed}x'), + trailing: Text( + '${playerSettings.preferredDefaultSpeed}x', + style: + TextStyle(color: primaryColor, fontWeight: FontWeight.bold), + ), leading: const Icon(Icons.speed), onPressed: (context) async { final newSpeed = await showDialog( @@ -70,6 +75,8 @@ class PlayerSettingsPage extends HookConsumerWidget { title: const Text('Speed Options'), description: Text( playerSettings.speedOptions.map((e) => '${e}x').join(', '), + style: + TextStyle(fontWeight: FontWeight.bold, color: primaryColor), ), leading: const Icon(Icons.speed), onPressed: (context) async { @@ -104,7 +111,10 @@ class PlayerSettingsPage extends HookConsumerWidget { TextSpan( text: playerSettings .minimumPositionForReporting.smartBinaryFormat, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: primaryColor, + ), ), const TextSpan(text: ' of the book'), ], @@ -141,7 +151,10 @@ class PlayerSettingsPage extends HookConsumerWidget { TextSpan( text: playerSettings .markCompleteWhenTimeLeft.smartBinaryFormat, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: primaryColor, + ), ), const TextSpan(text: ' left in the book'), ], @@ -178,7 +191,10 @@ class PlayerSettingsPage extends HookConsumerWidget { TextSpan( text: playerSettings .playbackReportInterval.smartBinaryFormat, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: primaryColor, + ), ), const TextSpan(text: ' to the server'), ], diff --git a/lib/settings/view/shake_detector_settings_page.dart b/lib/settings/view/shake_detector_settings_page.dart new file mode 100644 index 0000000..98c5b8d --- /dev/null +++ b/lib/settings/view/shake_detector_settings_page.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/settings/app_settings_provider.dart'; +import 'package:vaani/settings/models/app_settings.dart'; +import 'package:vaani/settings/view/buttons.dart'; +import 'package:vaani/settings/view/simple_settings_page.dart'; +import 'package:vaani/shared/extensions/enum.dart'; + +class ShakeDetectorSettingsPage extends HookConsumerWidget { + const ShakeDetectorSettingsPage({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appSettings = ref.watch(appSettingsProvider); + final shakeDetectionSettings = appSettings.shakeDetectionSettings; + final isShakeDetectionEnabled = shakeDetectionSettings.isEnabled; + final selectedValueColor = isShakeDetectionEnabled + ? Theme.of(context).colorScheme.primary + : Theme.of(context).disabledColor; + + return SimpleSettingsPage( + title: const Text('Shake Detector Settings'), + sections: [ + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + tiles: [ + SettingsTile.switchTile( + leading: shakeDetectionSettings.isEnabled + ? const Icon(Icons.vibration) + : const Icon(Icons.not_interested), + title: const Text('Enable Shake Detection'), + description: const Text( + 'Enable shake detection to do various actions', + ), + initialValue: shakeDetectionSettings.isEnabled, + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.shakeDetectionSettings( + isEnabled: value, + ), + ); + }, + ), + ], + ), + + // Shake Detection Settings + SettingsSection( + tiles: [ + SettingsTile( + enabled: isShakeDetectionEnabled, + leading: const Icon(Icons.flag_circle), + title: const Text('Shake Activation Threshold'), + description: const Text( + 'The higher the threshold, the harder you need to shake', + ), + trailing: Text( + '${shakeDetectionSettings.threshold} m/s²', + style: TextStyle( + color: selectedValueColor, + fontWeight: FontWeight.bold, + ), + ), + onPressed: (context) async { + final newThreshold = await showDialog( + context: context, + builder: (context) => ShakeForceSelector( + initialValue: shakeDetectionSettings.threshold, + ), + ); + + if (newThreshold != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.shakeDetectionSettings( + threshold: newThreshold, + ), + ); + } + }, + ), + + // shake action + SettingsTile( + enabled: isShakeDetectionEnabled, + leading: const Icon(Icons.directions_run), + title: const Text('Shake Action'), + description: const Text( + 'The action to perform when a shake is detected', + ), + trailing: Icon( + shakeDetectionSettings.shakeAction.icon, + color: selectedValueColor, + ), + onPressed: (context) async { + final newShakeAction = await showDialog( + context: context, + builder: (context) => ShakeActionSelector( + initialValue: shakeDetectionSettings.shakeAction, + ), + ); + + if (newShakeAction != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.shakeDetectionSettings( + shakeAction: newShakeAction, + ), + ); + } + }, + ), + + // shake feedback + SettingsTile( + enabled: isShakeDetectionEnabled, + leading: const Icon(Icons.feedback), + title: const Text('Shake Feedback'), + description: const Text( + 'The feedback to give when a shake is detected', + ), + trailing: shakeDetectionSettings.feedback.isEmpty + ? Icon( + Icons.not_interested, + color: Theme.of(context).disabledColor, + ) + : Wrap( + spacing: 8.0, + children: shakeDetectionSettings.feedback.map( + (feedback) { + return Icon( + feedback.icon, + color: selectedValueColor, + ); + }, + ).toList(), + ), + onPressed: (context) async { + final newFeedback = + await showDialog>( + context: context, + builder: (context) => ShakeFeedbackSelector( + initialValue: shakeDetectionSettings.feedback, + ), + ); + + if (newFeedback != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.shakeDetectionSettings( + feedback: newFeedback, + ), + ); + } + }, + ), + ], + ), + ], + ); + } +} + +class ShakeFeedbackSelector extends HookConsumerWidget { + const ShakeFeedbackSelector({ + super.key, + this.initialValue = const {ShakeDetectedFeedback.vibrate}, + }); + + final Set initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final feedback = useState(initialValue); + return AlertDialog( + title: const Text('Select Shake Feedback'), + content: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: ShakeDetectedFeedback.values + .map( + (feedbackType) => ChoiceChip( + avatar: Icon(feedbackType.icon), + label: Text(feedbackType.pascalCase), + tooltip: feedbackType.description, + onSelected: (val) { + if (feedback.value.contains(feedbackType)) { + feedback.value = feedback.value + .where((element) => element != feedbackType) + .toSet(); + } else { + feedback.value = {...feedback.value, feedbackType}; + } + }, + selected: feedback.value.contains(feedbackType), + ), + ) + .toList(), + ), + actions: [ + const CancelButton(), + OkButton( + onPressed: () { + Navigator.of(context).pop(feedback.value); + }, + ), + ], + ); + } +} + +class ShakeActionSelector extends HookConsumerWidget { + const ShakeActionSelector({ + super.key, + this.initialValue = ShakeAction.resetSleepTimer, + }); + + final ShakeAction initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final shakeAction = useState(initialValue); + return AlertDialog( + title: const Text('Select Shake Action'), + content: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: ShakeAction.values + .map( + // chips with radio buttons as one of the options can be selected + (action) => ChoiceChip( + avatar: Icon(action.icon), + label: Text(action.pascalCase), + onSelected: (val) { + shakeAction.value = action; + }, + selected: shakeAction.value == action, + ), + ) + .toList(), + ), + actions: [ + const CancelButton(), + OkButton( + onPressed: () { + Navigator.of(context).pop(shakeAction.value); + }, + ), + ], + ); + } +} + +class ShakeForceSelector extends HookConsumerWidget { + const ShakeForceSelector({ + super.key, + this.initialValue = 6, + }); + + final double initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final shakeForce = useState(initialValue); + final controller = useTextEditingController(text: initialValue.toString()); + return AlertDialog( + title: const Text('Select Shake Activation Threshold'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + autofocus: true, + controller: controller, + onChanged: (value) { + final newThreshold = double.tryParse(value); + if (newThreshold != null) { + shakeForce.value = newThreshold; + } + }, + keyboardType: TextInputType.number, + decoration: InputDecoration( + // clear button + suffix: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + controller.clear(); + shakeForce.value = 0; + }, + ), + helper: const Text( + 'Enter a number to set the threshold in m/s²', + ), + ), + ), + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: ShakeForce.values + .map( + (force) => ChoiceChip( + label: Text(force.pascalCase), + onSelected: (val) { + controller.text = force.threshold.toString(); + shakeForce.value = force.threshold; + }, + selected: shakeForce.value == force.threshold, + ), + ) + .toList(), + ), + ], + ), + actions: [ + const CancelButton(), + OkButton( + onPressed: () { + Navigator.of(context).pop(shakeForce.value); + }, + ), + ], + ); + } +} + +enum ShakeForce { + whisper(0.5), + low(2.5), + medium(5), + high(7.5), + storm(10), + hurricane(15), + earthquake(20), + meteorShower(30), + supernova(40), + blackHole(50); + + const ShakeForce(this.threshold); + + final double threshold; +} + +extension ShakeActionIcon on ShakeAction { + IconData? get icon { + switch (this) { + case ShakeAction.none: + return Icons.not_interested; + case ShakeAction.resetSleepTimer: + return Icons.timer; + case ShakeAction.playPause: + return Icons.play_arrow; + // case ShakeAction.nextChapter: + // return Icons.skip_next; + // case ShakeAction.previousChapter: + // return Icons.skip_previous; + // case ShakeAction.volumeUp: + // return Icons.volume_up; + // case ShakeAction.volumeDown: + // return Icons.volume_down; + case ShakeAction.fastForward: + return Icons.fast_forward; + case ShakeAction.rewind: + return Icons.fast_rewind; + default: + return Icons.question_mark; + } + } +} + +extension on ShakeDetectedFeedback { + IconData? get icon { + switch (this) { + case ShakeDetectedFeedback.vibrate: + return Icons.vibration; + case ShakeDetectedFeedback.beep: + return Icons.volume_up; + default: + return Icons.question_mark; + } + } + + String get description { + switch (this) { + case ShakeDetectedFeedback.vibrate: + return 'Vibrate the device'; + case ShakeDetectedFeedback.beep: + return 'Play a beep sound'; + default: + return 'Unknown'; + } + } +} diff --git a/lib/settings/view/widgets/navigation_with_switch_tile.dart b/lib/settings/view/widgets/navigation_with_switch_tile.dart new file mode 100644 index 0000000..dbc8ecf --- /dev/null +++ b/lib/settings/view/widgets/navigation_with_switch_tile.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; + +class NavigationWithSwitchTile extends AbstractSettingsTile { + const NavigationWithSwitchTile({ + this.leading, + // this.trailing, + required this.value, + required this.title, + this.description, + this.descriptionInlineIos = false, + this.onPressed, + this.enabled = true, + this.backgroundColor, + super.key, + this.onToggle, + }); + + final Widget title; + final Widget? description; + final Color? backgroundColor; + final bool descriptionInlineIos; + final bool enabled; + final Widget? leading; + final Function(BuildContext)? onPressed; + final bool value; + final Function(bool)? onToggle; + + @override + Widget build(BuildContext context) { + return SettingsTile.navigation( + title: title, + description: description, + backgroundColor: backgroundColor, + descriptionInlineIos: descriptionInlineIos, + enabled: enabled, + leading: leading, + onPressed: onPressed, + trailing: IntrinsicHeight( + child: Row( + children: [ + VerticalDivider( + color: Theme.of(context).dividerColor.withOpacity(0.5), + indent: 8.0, + endIndent: 8.0, + ), + Switch.adaptive( + value: value, + onChanged: onToggle, + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/extensions/duration_format.dart b/lib/shared/extensions/duration_format.dart index d871424..09906cf 100644 --- a/lib/shared/extensions/duration_format.dart +++ b/lib/shared/extensions/duration_format.dart @@ -16,6 +16,16 @@ extension DurationFormat on Duration { return '${seconds}s'; } } + + String get smartSingleFormat { + if (inHours > 0) { + return '${inHours}h'; + } else if (inMinutes > 0) { + return '${inMinutes}m'; + } else { + return '${inSeconds}s'; + } + } } extension OnlyTime on DateTime { diff --git a/lib/shared/extensions/enum.dart b/lib/shared/extensions/enum.dart new file mode 100644 index 0000000..e0e6c09 --- /dev/null +++ b/lib/shared/extensions/enum.dart @@ -0,0 +1,25 @@ +extension TitleCase on Enum { + String get properName { + final name = toString().split('.').last; + return name[0].toUpperCase() + name.substring(1); + } + + String get titleCase { + return name + .replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(0)}') + .trim(); + } + + String get pascalCase { + // capitalize the first letter of each word + return name + .replaceAllMapped( + RegExp(r'([A-Z])'), + (match) => ' ${match.group(0)}', + ) + .trim() + .split(' ') + .map((word) => word[0].toUpperCase() + word.substring(1)) + .join(' '); + } +} diff --git a/pubspec.lock b/pubspec.lock index f7059ef..969bcca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1363,6 +1363,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: fe8f90e1827f86a4f722b819799ecac8a24789a39c6d562ea316bcaeb8b1ec61 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "735a5fef0f284de0ad9449a5ed7d36ba017c6f59b5b20ac64418af4a6bd35ee7" + url: "https://pub.dev" + source: hosted + version: "0.0.1" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 11eddba..8bbeef6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -88,6 +88,7 @@ dependencies: path: ./shelfsdk shimmer: ^3.0.0 url_launcher: ^6.2.6 + vibration: ^2.0.0 dev_dependencies: build_runner: ^2.4.9 custom_lint: ^0.6.4 @@ -112,6 +113,7 @@ flutter: assets: - assets/ - assets/animations/ + - assets/sounds/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see