feat: add shake detection functionality (#36)

* feat: add shake detection functionality and integrate vibration support

* feat: add shake detector settings page
This commit is contained in:
Dr.Blank 2024-09-28 01:27:56 -04:00 committed by GitHub
parent 2e3b1de529
commit b229c4f2f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1423 additions and 158 deletions

View file

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:label="Vaani"
android:name="${applicationName}"

BIN
assets/sounds/beep.mp3 Normal file

Binary file not shown.

View file

@ -281,7 +281,7 @@ extension FormatNotificationTitle on String {
(match) {
final type = match.group(1);
return NotificationTitleType.values
.firstWhere((element) => element.stringValue == type)
.firstWhere((element) => element.name == type)
.extractFrom(book) ??
match.group(0) ??
'';

View file

@ -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';
}
}
}

View file

@ -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);
}

View file

@ -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<void> 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);
}
}

View file

@ -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<ShakeDetector, core.ShakeDetector?>.internal(
ShakeDetector.new,
name: r'shakeDetectorProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$shakeDetectorHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ShakeDetector = AutoDisposeNotifier<core.ShakeDetector?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -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();
}

View file

@ -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;

View file

@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$sleepTimerHash() => r'ad77e82c1b513bbc62815c64ce1ed403f92fc055';
String _$sleepTimerHash() => r'9d9f20267da91e5483151b58b7d4d7c0762c3ca7';
/// See also [SleepTimer].
@ProviderFor(SleepTimer)

View file

@ -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());
}

View file

@ -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(

View file

@ -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(

View file

@ -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<String, dynamic> 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<ShakeDetectedFeedback> 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<String, dynamic> json) =>
_$ShakeDetectionSettingsFromJson(json);
}
enum ShakeDirection { horizontal, vertical }
enum ShakeAction {
none,
playPause,
resetSleepTimer,
fastForward,
rewind,
}
enum ShakeDetectedFeedback { vibrate, beep }

View file

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<ShakeDetectedFeedback> 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<String, dynamic> 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<ShakeDetectionSettings> 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<ShakeDetectedFeedback> 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<ShakeDetectedFeedback>,
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<ShakeDetectedFeedback> 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<ShakeDetectedFeedback>,
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<ShakeDetectedFeedback> 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<String, dynamic> 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<ShakeDetectedFeedback> _feedback;
@override
@JsonKey()
Set<ShakeDetectedFeedback> 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<String, dynamic> 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<ShakeDetectedFeedback> feedback,
final double beepVolume,
final Duration shakeTriggerCoolDown,
final int shakeTriggerCount,
final Duration samplingPeriod}) = _$ShakeDetectionSettingsImpl;
factory _ShakeDetectionSettings.fromJson(Map<String, dynamic> json) =
_$ShakeDetectionSettingsImpl.fromJson;
@override
bool get isEnabled;
@override
ShakeDirection get direction;
@override
double get threshold;
@override
ShakeAction get shakeAction;
@override
Set<ShakeDetectedFeedback> 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;
}

View file

@ -24,6 +24,10 @@ _$AppSettingsImpl _$$AppSettingsImplFromJson(Map<String, dynamic> json) =>
? const NotificationSettings()
: NotificationSettings.fromJson(
json['notificationSettings'] as Map<String, dynamic>),
shakeDetectionSettings: json['shakeDetectionSettings'] == null
? const ShakeDetectionSettings()
: ShakeDetectionSettings.fromJson(
json['shakeDetectionSettings'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
@ -32,6 +36,7 @@ Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
'playerSettings': instance.playerSettings,
'downloadSettings': instance.downloadSettings,
'notificationSettings': instance.notificationSettings,
'shakeDetectionSettings': instance.shakeDetectionSettings,
};
_$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map<String, dynamic> json) =>
@ -274,3 +279,63 @@ const _$NotificationMediaControlEnumMap = {
NotificationMediaControl.skipToNextChapter: 'skipToNextChapter',
NotificationMediaControl.skipToPreviousChapter: 'skipToPreviousChapter',
};
_$ShakeDetectionSettingsImpl _$$ShakeDetectionSettingsImplFromJson(
Map<String, dynamic> 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<dynamic>?)
?.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<String, dynamic> _$$ShakeDetectionSettingsImplToJson(
_$ShakeDetectionSettingsImpl instance) =>
<String, dynamic>{
'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',
};

View file

@ -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(

View file

@ -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<String?> showNotificationTitlePicker(
BuildContext context, {
required String initialValue,
required String title,
}) async {
return showDialog<String>(
context: context,
builder: (context) {
return NotificationTitlePicker(initialValue: initialValue, title: title);
},
);
}

View file

@ -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'),
],

View file

@ -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<double>(
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<ShakeAction>(
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<Set<ShakeDetectedFeedback>>(
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<ShakeDetectedFeedback> 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';
}
}
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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 {

View file

@ -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(' ');
}
}

View file

@ -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:

View file

@ -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