From 17cf94646996846334299f49b6b9dbaeedfa8a38 Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Fri, 27 Sep 2024 23:47:52 -0400 Subject: [PATCH] feat: add shake detector settings page --- .../shake_detection/core/shake_detector.dart | 2 +- .../providers/shake_detector.dart | 4 +- .../providers/shake_detector.g.dart | 2 +- lib/router/constants.dart | 5 + lib/router/router.dart | 8 + lib/settings/models/app_settings.dart | 36 +- lib/settings/models/app_settings.freezed.dart | 55 +-- lib/settings/models/app_settings.g.dart | 28 +- lib/settings/view/app_settings_page.dart | 115 +++-- .../view/notification_settings_page.dart | 62 ++- lib/settings/view/player_settings_page.dart | 24 +- .../view/shake_detector_settings_page.dart | 395 ++++++++++++++++++ .../widgets/navigation_with_switch_tile.dart | 56 +++ lib/shared/extensions/enum.dart | 25 ++ 14 files changed, 613 insertions(+), 204 deletions(-) create mode 100644 lib/settings/view/shake_detector_settings_page.dart create mode 100644 lib/settings/view/widgets/navigation_with_switch_tile.dart create mode 100644 lib/shared/extensions/enum.dart diff --git a/lib/features/shake_detection/core/shake_detector.dart b/lib/features/shake_detection/core/shake_detector.dart index 5cc34ee..4b7b719 100644 --- a/lib/features/shake_detection/core/shake_detector.dart +++ b/lib/features/shake_detection/core/shake_detector.dart @@ -36,7 +36,7 @@ class ShakeDetector { _accelerometerSubscription = userAccelerometerEventStream(samplingPeriod: _settings.samplingPeriod) .listen((event) { - if (event.rms > (_settings.force?.threshold ?? _settings.threshold)) { + if (event.rms > _settings.threshold) { _currentShakeCount++; if (_currentShakeCount >= _settings.shakeTriggerCount && diff --git a/lib/features/shake_detection/providers/shake_detector.dart b/lib/features/shake_detection/providers/shake_detector.dart index 1902187..956fc16 100644 --- a/lib/features/shake_detection/providers/shake_detector.dart +++ b/lib/features/shake_detection/providers/shake_detector.dart @@ -65,7 +65,7 @@ class ShakeDetector extends _$ShakeDetector { }) { final player = ref.watch(simpleAudiobookPlayerProvider); switch (shakeAction) { - case ShakeAction.sleepTimerReset: + case ShakeAction.resetSleepTimer: _logger.fine('Resetting sleep timer'); ref.read(sleepTimerProvider.notifier).restartTimer(); break; @@ -133,6 +133,6 @@ extension on ShakeDetectionSettings { } bool get shouldActOnSleepTimer { - return {ShakeAction.sleepTimerReset}.contains(shakeAction); + 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 index 2a158f2..c3035b8 100644 --- a/lib/features/shake_detection/providers/shake_detector.g.dart +++ b/lib/features/shake_detection/providers/shake_detector.g.dart @@ -6,7 +6,7 @@ part of 'shake_detector.dart'; // RiverpodGenerator // ************************************************************************** -String _$shakeDetectorHash() => r'2b04fe1be82c48f4c3feb9a2c450f382f6839d05'; +String _$shakeDetectorHash() => r'7bfbdd22f2f43ef3e3858d226d1eb78923e8114d'; /// See also [ShakeDetector]. @ProviderFor(ShakeDetector) 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 7091b57..09a07e2 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -195,11 +195,10 @@ class ShakeDetectionSettings with _$ShakeDetectionSettings { const factory ShakeDetectionSettings({ @Default(true) bool isEnabled, @Default(ShakeDirection.horizontal) ShakeDirection direction, - @Default(6) double threshold, - @Default(ShakeForce.medium) ShakeForce? force, - @Default(ShakeAction.sleepTimerReset) ShakeAction shakeAction, - @Default([ShakeDetectedFeedback.vibrate, ShakeDetectedFeedback.beep]) - List feedback, + @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 @@ -218,37 +217,12 @@ class ShakeDetectionSettings with _$ShakeDetectionSettings { enum ShakeDirection { horizontal, vertical } -enum ShakeForce { - low(2.5), - medium(10), - high(15), - leafRustle(3), - breeze(7), - storm(12), - hurricane(18), - earthquake(25), - meteorShower(30), - supernova(40), - blackHole(50); - - const ShakeForce(this.threshold); - - final double threshold; - - String get properName { - return name - .replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(0)}') - .trim(); - } -} - enum ShakeAction { none, playPause, - sleepTimerReset, + resetSleepTimer, fastForward, rewind, - custom, } enum ShakeDetectedFeedback { vibrate, beep } diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index 9f94770..3c1d9f4 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -2429,10 +2429,8 @@ mixin _$ShakeDetectionSettings { bool get isEnabled => throw _privateConstructorUsedError; ShakeDirection get direction => throw _privateConstructorUsedError; double get threshold => throw _privateConstructorUsedError; - ShakeForce? get force => throw _privateConstructorUsedError; ShakeAction get shakeAction => throw _privateConstructorUsedError; - List get feedback => - throw _privateConstructorUsedError; + Set get feedback => throw _privateConstructorUsedError; double get beepVolume => throw _privateConstructorUsedError; /// the duration to wait before the shake detection is enabled again @@ -2464,9 +2462,8 @@ abstract class $ShakeDetectionSettingsCopyWith<$Res> { {bool isEnabled, ShakeDirection direction, double threshold, - ShakeForce? force, ShakeAction shakeAction, - List feedback, + Set feedback, double beepVolume, Duration shakeTriggerCoolDown, int shakeTriggerCount, @@ -2492,7 +2489,6 @@ class _$ShakeDetectionSettingsCopyWithImpl<$Res, Object? isEnabled = null, Object? direction = null, Object? threshold = null, - Object? force = freezed, Object? shakeAction = null, Object? feedback = null, Object? beepVolume = null, @@ -2513,10 +2509,6 @@ class _$ShakeDetectionSettingsCopyWithImpl<$Res, ? _value.threshold : threshold // ignore: cast_nullable_to_non_nullable as double, - force: freezed == force - ? _value.force - : force // ignore: cast_nullable_to_non_nullable - as ShakeForce?, shakeAction: null == shakeAction ? _value.shakeAction : shakeAction // ignore: cast_nullable_to_non_nullable @@ -2524,7 +2516,7 @@ class _$ShakeDetectionSettingsCopyWithImpl<$Res, feedback: null == feedback ? _value.feedback : feedback // ignore: cast_nullable_to_non_nullable - as List, + as Set, beepVolume: null == beepVolume ? _value.beepVolume : beepVolume // ignore: cast_nullable_to_non_nullable @@ -2558,9 +2550,8 @@ abstract class _$$ShakeDetectionSettingsImplCopyWith<$Res> {bool isEnabled, ShakeDirection direction, double threshold, - ShakeForce? force, ShakeAction shakeAction, - List feedback, + Set feedback, double beepVolume, Duration shakeTriggerCoolDown, int shakeTriggerCount, @@ -2585,7 +2576,6 @@ class __$$ShakeDetectionSettingsImplCopyWithImpl<$Res> Object? isEnabled = null, Object? direction = null, Object? threshold = null, - Object? force = freezed, Object? shakeAction = null, Object? feedback = null, Object? beepVolume = null, @@ -2606,10 +2596,6 @@ class __$$ShakeDetectionSettingsImplCopyWithImpl<$Res> ? _value.threshold : threshold // ignore: cast_nullable_to_non_nullable as double, - force: freezed == force - ? _value.force - : force // ignore: cast_nullable_to_non_nullable - as ShakeForce?, shakeAction: null == shakeAction ? _value.shakeAction : shakeAction // ignore: cast_nullable_to_non_nullable @@ -2617,7 +2603,7 @@ class __$$ShakeDetectionSettingsImplCopyWithImpl<$Res> feedback: null == feedback ? _value._feedback : feedback // ignore: cast_nullable_to_non_nullable - as List, + as Set, beepVolume: null == beepVolume ? _value.beepVolume : beepVolume // ignore: cast_nullable_to_non_nullable @@ -2644,13 +2630,12 @@ class _$ShakeDetectionSettingsImpl implements _ShakeDetectionSettings { const _$ShakeDetectionSettingsImpl( {this.isEnabled = true, this.direction = ShakeDirection.horizontal, - this.threshold = 6, - this.force = ShakeForce.medium, - this.shakeAction = ShakeAction.sleepTimerReset, - final List feedback = const [ + 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, @@ -2671,17 +2656,14 @@ class _$ShakeDetectionSettingsImpl implements _ShakeDetectionSettings { final double threshold; @override @JsonKey() - final ShakeForce? force; - @override - @JsonKey() final ShakeAction shakeAction; - final List _feedback; + final Set _feedback; @override @JsonKey() - List get feedback { - if (_feedback is EqualUnmodifiableListView) return _feedback; + Set get feedback { + if (_feedback is EqualUnmodifiableSetView) return _feedback; // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_feedback); + return EqualUnmodifiableSetView(_feedback); } @override @@ -2705,7 +2687,7 @@ class _$ShakeDetectionSettingsImpl implements _ShakeDetectionSettings { @override String toString() { - return 'ShakeDetectionSettings(isEnabled: $isEnabled, direction: $direction, threshold: $threshold, force: $force, shakeAction: $shakeAction, feedback: $feedback, beepVolume: $beepVolume, shakeTriggerCoolDown: $shakeTriggerCoolDown, shakeTriggerCount: $shakeTriggerCount, samplingPeriod: $samplingPeriod)'; + return 'ShakeDetectionSettings(isEnabled: $isEnabled, direction: $direction, threshold: $threshold, shakeAction: $shakeAction, feedback: $feedback, beepVolume: $beepVolume, shakeTriggerCoolDown: $shakeTriggerCoolDown, shakeTriggerCount: $shakeTriggerCount, samplingPeriod: $samplingPeriod)'; } @override @@ -2719,7 +2701,6 @@ class _$ShakeDetectionSettingsImpl implements _ShakeDetectionSettings { other.direction == direction) && (identical(other.threshold, threshold) || other.threshold == threshold) && - (identical(other.force, force) || other.force == force) && (identical(other.shakeAction, shakeAction) || other.shakeAction == shakeAction) && const DeepCollectionEquality().equals(other._feedback, _feedback) && @@ -2740,7 +2721,6 @@ class _$ShakeDetectionSettingsImpl implements _ShakeDetectionSettings { isEnabled, direction, threshold, - force, shakeAction, const DeepCollectionEquality().hash(_feedback), beepVolume, @@ -2770,9 +2750,8 @@ abstract class _ShakeDetectionSettings implements ShakeDetectionSettings { {final bool isEnabled, final ShakeDirection direction, final double threshold, - final ShakeForce? force, final ShakeAction shakeAction, - final List feedback, + final Set feedback, final double beepVolume, final Duration shakeTriggerCoolDown, final int shakeTriggerCount, @@ -2788,11 +2767,9 @@ abstract class _ShakeDetectionSettings implements ShakeDetectionSettings { @override double get threshold; @override - ShakeForce? get force; - @override ShakeAction get shakeAction; @override - List get feedback; + Set get feedback; @override double get beepVolume; diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index 7b97dd0..3b0784f 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -287,16 +287,14 @@ _$ShakeDetectionSettingsImpl _$$ShakeDetectionSettingsImplFromJson( direction: $enumDecodeNullable(_$ShakeDirectionEnumMap, json['direction']) ?? ShakeDirection.horizontal, - threshold: (json['threshold'] as num?)?.toDouble() ?? 6, - force: $enumDecodeNullable(_$ShakeForceEnumMap, json['force']) ?? - ShakeForce.medium, + threshold: (json['threshold'] as num?)?.toDouble() ?? 5, shakeAction: $enumDecodeNullable(_$ShakeActionEnumMap, json['shakeAction']) ?? - ShakeAction.sleepTimerReset, + ShakeAction.resetSleepTimer, feedback: (json['feedback'] as List?) ?.map((e) => $enumDecode(_$ShakeDetectedFeedbackEnumMap, e)) - .toList() ?? - const [ShakeDetectedFeedback.vibrate, ShakeDetectedFeedback.beep], + .toSet() ?? + const {ShakeDetectedFeedback.vibrate, ShakeDetectedFeedback.beep}, beepVolume: (json['beepVolume'] as num?)?.toDouble() ?? 0.5, shakeTriggerCoolDown: json['shakeTriggerCoolDown'] == null ? const Duration(seconds: 5) @@ -314,7 +312,6 @@ Map _$$ShakeDetectionSettingsImplToJson( 'isEnabled': instance.isEnabled, 'direction': _$ShakeDirectionEnumMap[instance.direction]!, 'threshold': instance.threshold, - 'force': _$ShakeForceEnumMap[instance.force], 'shakeAction': _$ShakeActionEnumMap[instance.shakeAction]!, 'feedback': instance.feedback .map((e) => _$ShakeDetectedFeedbackEnumMap[e]!) @@ -330,27 +327,12 @@ const _$ShakeDirectionEnumMap = { ShakeDirection.vertical: 'vertical', }; -const _$ShakeForceEnumMap = { - ShakeForce.low: 'low', - ShakeForce.medium: 'medium', - ShakeForce.high: 'high', - ShakeForce.leafRustle: 'leafRustle', - ShakeForce.breeze: 'breeze', - ShakeForce.storm: 'storm', - ShakeForce.hurricane: 'hurricane', - ShakeForce.earthquake: 'earthquake', - ShakeForce.meteorShower: 'meteorShower', - ShakeForce.supernova: 'supernova', - ShakeForce.blackHole: 'blackHole', -}; - const _$ShakeActionEnumMap = { ShakeAction.none: 'none', ShakeAction.playPause: 'playPause', - ShakeAction.sleepTimerReset: 'sleepTimerReset', + ShakeAction.resetSleepTimer: 'resetSleepTimer', ShakeAction.fastForward: 'fastForward', ShakeAction.rewind: 'rewind', - ShakeAction.custom: 'custom', }; const _$ShakeDetectedFeedbackEnumMap = { 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 4584d7d..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 = @@ -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) { @@ -362,7 +361,7 @@ class NotificationTitlePicker extends HookConsumerWidget { children: NotificationTitleType.values .map( (type) => ActionChip( - label: Text(type.name), + label: Text(type.pascalCase), onPressed: () { final text = controller.text; final newText = '$text\$${type.name}'; @@ -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/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(' '); + } +}