feat: extensive settings for media controls through notification (#28)

* feat: add notification settings customisation options

* feat: add notification settings page and update routing
This commit is contained in:
Dr.Blank 2024-09-25 03:13:42 -04:00 committed by GitHub
parent 721b0a87fc
commit 3cf0a0b124
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1391 additions and 376 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -8,6 +8,9 @@ import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart'; import 'package:just_audio_background/just_audio_background.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/models/app_settings.dart';
import 'package:vaani/shared/extensions/model_conversions.dart';
final _logger = Logger('AudiobookPlayer'); final _logger = Logger('AudiobookPlayer');
@ -81,6 +84,7 @@ class AudiobookPlayer extends AudioPlayer {
List<Uri>? downloadedUris, List<Uri>? downloadedUris,
Uri? artworkUri, Uri? artworkUri,
}) async { }) async {
final appSettings = loadOrCreateAppSettings();
// if the book is null, stop the player // if the book is null, stop the player
if (book == null) { if (book == null) {
_book = null; _book = null;
@ -128,8 +132,10 @@ class AudiobookPlayer extends AudioPlayer {
// Specify a unique ID for each media item: // Specify a unique ID for each media item:
id: book.libraryItemId + track.index.toString(), id: book.libraryItemId + track.index.toString(),
// Metadata to display in the notification: // Metadata to display in the notification:
album: book.metadata.title, title: appSettings.notificationSettings.primaryTitle
title: book.metadata.title ?? track.title, .formatNotificationTitle(book),
album: appSettings.notificationSettings.secondaryTitle
.formatNotificationTitle(book),
artUri: artworkUri ?? artUri: artworkUri ??
Uri.parse( Uri.parse(
'$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800', '$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800',
@ -198,7 +204,7 @@ class AudiobookPlayer extends AudioPlayer {
@override @override
Stream<Duration> get positionStream { Stream<Duration> get positionStream {
// return the positioninbook stream // return the positionInBook stream
return super.positionStream.map((position) { return super.positionStream.map((position) {
if (_book == null) { if (_book == null) {
return Duration.zero; return Duration.zero;
@ -267,3 +273,42 @@ Uri _getUri(
return uri ?? return uri ??
Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token'); Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token');
} }
extension FormatNotificationTitle on String {
String formatNotificationTitle(BookExpanded book) {
return replaceAllMapped(
RegExp(r'\$(\w+)'),
(match) {
final type = match.group(1);
return NotificationTitleType.values
.firstWhere((element) => element.stringValue == type)
.extractFrom(book) ??
match.group(0) ??
'';
},
);
}
}
extension NotificationTitleUtils on NotificationTitleType {
String? extractFrom(BookExpanded book) {
var bookMetadataExpanded = book.metadata.asBookMetadataExpanded;
switch (this) {
case NotificationTitleType.bookTitle:
return bookMetadataExpanded.title;
case NotificationTitleType.chapterTitle:
// TODO: implement chapter title; depends on https://github.com/Dr-Blank/Vaani/issues/2
return bookMetadataExpanded.title;
case NotificationTitleType.author:
return bookMetadataExpanded.authorName;
case NotificationTitleType.narrator:
return bookMetadataExpanded.narratorName;
case NotificationTitleType.series:
return bookMetadataExpanded.seriesName;
case NotificationTitleType.subtitle:
return bookMetadataExpanded.subtitle;
case NotificationTitleType.year:
return bookMetadataExpanded.publishedYear;
}
}
}

View file

@ -0,0 +1,62 @@
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio_background/just_audio_background.dart'
show JustAudioBackground, NotificationConfig;
import 'package:just_audio_media_kit/just_audio_media_kit.dart'
show JustAudioMediaKit;
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/models/app_settings.dart';
Future<void> configurePlayer() async {
// for playing audio on windows, linux
JustAudioMediaKit.ensureInitialized();
// for configuring how this app will interact with other audio apps
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.speech());
final appSettings = loadOrCreateAppSettings();
// for playing audio in the background
await JustAudioBackground.init(
androidNotificationChannelId: 'com.vaani.bg_demo.channel.audio',
androidNotificationChannelName: 'Audio playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
androidNotificationChannelDescription: 'Audio playback in the background',
androidNotificationIcon: 'drawable/ic_stat_notification_logo',
rewindInterval: appSettings.notificationSettings.rewindInterval,
fastForwardInterval: appSettings.notificationSettings.fastForwardInterval,
androidShowNotificationBadge: false,
notificationConfigBuilder: (state) {
final controls = [
if (appSettings.notificationSettings.mediaControls
.contains(NotificationMediaControl.skipToPreviousChapter) &&
state.hasPrevious)
MediaControl.skipToPrevious,
if (appSettings.notificationSettings.mediaControls
.contains(NotificationMediaControl.rewind))
MediaControl.rewind,
if (state.playing) MediaControl.pause else MediaControl.play,
if (appSettings.notificationSettings.mediaControls
.contains(NotificationMediaControl.fastForward))
MediaControl.fastForward,
if (appSettings.notificationSettings.mediaControls
.contains(NotificationMediaControl.skipToNextChapter) &&
state.hasNext)
MediaControl.skipToNext,
if (appSettings.notificationSettings.mediaControls
.contains(NotificationMediaControl.stop))
MediaControl.stop,
];
return NotificationConfig(
controls: controls,
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
},
);
},
);
}

View file

@ -1,15 +1,11 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio_background/just_audio_background.dart'
show JustAudioBackground;
import 'package:just_audio_media_kit/just_audio_media_kit.dart'
show JustAudioMediaKit;
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:vaani/api/server_provider.dart'; import 'package:vaani/api/server_provider.dart';
import 'package:vaani/db/storage.dart'; import 'package:vaani/db/storage.dart';
import 'package:vaani/features/downloads/providers/download_manager.dart'; import 'package:vaani/features/downloads/providers/download_manager.dart';
import 'package:vaani/features/playback_reporting/providers/playback_reporter_provider.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/player/providers/audiobook_player.dart';
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'; import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart';
import 'package:vaani/router/router.dart'; import 'package:vaani/router/router.dart';
@ -31,23 +27,12 @@ void main() async {
); );
}); });
// for playing audio on windows, linux
JustAudioMediaKit.ensureInitialized();
// initialize the storage // initialize the storage
await initStorage(); await initStorage();
// for configuring how this app will interact with other audio apps // initialize audio player
final session = await AudioSession.instance; await configurePlayer();
await session.configure(const AudioSessionConfiguration.speech());
// for playing audio in the background
await JustAudioBackground.init(
androidNotificationChannelId: 'com.vaani.bg_demo.channel.audio',
androidNotificationChannelName: 'Audio playback',
androidNotificationOngoing: true,
androidNotificationIcon: 'mipmap/launcher_icon',
);
// run the app // run the app
runApp( runApp(

View file

@ -28,9 +28,14 @@ class Routes {
name: 'settings', name: 'settings',
); );
static const autoSleepTimerSettings = _SimpleRoute( static const autoSleepTimerSettings = _SimpleRoute(
pathName: 'autosleeptimer', pathName: 'autoSleepTimer',
name: 'autoSleepTimerSettings', name: 'autoSleepTimerSettings',
// parentRoute: settings, parentRoute: settings,
);
static const notificationSettings = _SimpleRoute(
pathName: 'notifications',
name: 'notificationSettings',
parentRoute: settings,
); );
// search and explore // search and explore

View file

@ -12,6 +12,7 @@ import 'package:vaani/features/you/view/you_page.dart';
import 'package:vaani/pages/home_page.dart'; import 'package:vaani/pages/home_page.dart';
import 'package:vaani/settings/view/app_settings_page.dart'; 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/auto_sleep_timer_settings_page.dart';
import 'package:vaani/settings/view/notification_settings_page.dart';
import 'scaffold_with_nav_bar.dart'; import 'scaffold_with_nav_bar.dart';
import 'transitions/slide.dart'; import 'transitions/slide.dart';
@ -172,15 +173,22 @@ class MyAppRouter {
name: Routes.settings.name, name: Routes.settings.name,
// builder: (context, state) => const AppSettingsPage(), // builder: (context, state) => const AppSettingsPage(),
pageBuilder: defaultPageBuilder(const AppSettingsPage()), pageBuilder: defaultPageBuilder(const AppSettingsPage()),
), routes: [
GoRoute( GoRoute(
path: Routes.autoSleepTimerSettings.localPath, path: Routes.autoSleepTimerSettings.pathName,
name: Routes.autoSleepTimerSettings.name, name: Routes.autoSleepTimerSettings.name,
// builder: (context, state) => pageBuilder: defaultPageBuilder(
// const AutoSleepTimerSettingsPage(), const AutoSleepTimerSettingsPage(),
pageBuilder: defaultPageBuilder( ),
const AutoSleepTimerSettingsPage(), ),
), GoRoute(
path: Routes.notificationSettings.pathName,
name: Routes.notificationSettings.name,
pageBuilder: defaultPageBuilder(
const NotificationSettingsPage(),
),
),
],
), ),
GoRoute( GoRoute(
path: Routes.userManagement.localPath, path: Routes.userManagement.localPath,

View file

@ -11,25 +11,29 @@ final _box = AvailableHiveBoxes.userPrefsBox;
final _logger = Logger('AppSettingsProvider'); final _logger = Logger('AppSettingsProvider');
model.AppSettings readFromBoxOrCreate() { model.AppSettings loadOrCreateAppSettings() {
// see if the settings are already in the box // see if the settings are already in the box
model.AppSettings? settings;
if (_box.isNotEmpty) { if (_box.isNotEmpty) {
final foundSettings = _box.getAt(0); try {
_logger.fine('found settings in box: $foundSettings'); settings = _box.getAt(0);
return foundSettings; _logger.fine('found settings in box: $settings');
} catch (e) {
_logger.warning('error reading settings from box: $e'
'\nclearing box');
_box.clear();
}
} else { } else {
// create a new settings object _logger.fine('no settings found in box, creating new settings');
const settings = model.AppSettings();
_logger.fine('created new settings: $settings');
return settings;
} }
return settings ?? const model.AppSettings();
} }
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class AppSettings extends _$AppSettings { class AppSettings extends _$AppSettings {
@override @override
model.AppSettings build() { model.AppSettings build() {
state = readFromBoxOrCreate(); state = loadOrCreateAppSettings();
ref.listenSelf((_, __) { ref.listenSelf((_, __) {
writeToBox(); writeToBox();
}); });

View file

@ -6,7 +6,7 @@ part of 'app_settings_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$appSettingsHash() => r'e0e132b782b97f11d9791d4f1e45bf4ee67dd99b'; String _$appSettingsHash() => r'f51d55f117692d4fb9f4b4febf02906c0953d334';
/// See also [AppSettings]. /// See also [AppSettings].
@ProviderFor(AppSettings) @ProviderFor(AppSettings)

View file

@ -1,5 +1,6 @@
// a freezed class to store the settings of the app // a freezed class to store the settings of the app
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'app_settings.freezed.dart'; part 'app_settings.freezed.dart';
@ -14,6 +15,7 @@ class AppSettings with _$AppSettings {
@Default(ThemeSettings()) ThemeSettings themeSettings, @Default(ThemeSettings()) ThemeSettings themeSettings,
@Default(PlayerSettings()) PlayerSettings playerSettings, @Default(PlayerSettings()) PlayerSettings playerSettings,
@Default(DownloadSettings()) DownloadSettings downloadSettings, @Default(DownloadSettings()) DownloadSettings downloadSettings,
@Default(NotificationSettings()) NotificationSettings notificationSettings,
}) = _AppSettings; }) = _AppSettings;
factory AppSettings.fromJson(Map<String, dynamic> json) => factory AppSettings.fromJson(Map<String, dynamic> json) =>
@ -133,3 +135,53 @@ class DownloadSettings with _$DownloadSettings {
factory DownloadSettings.fromJson(Map<String, dynamic> json) => factory DownloadSettings.fromJson(Map<String, dynamic> json) =>
_$DownloadSettingsFromJson(json); _$DownloadSettingsFromJson(json);
} }
@freezed
class NotificationSettings with _$NotificationSettings {
const factory NotificationSettings({
@Default(Duration(seconds: 30)) Duration fastForwardInterval,
@Default(Duration(seconds: 10)) Duration rewindInterval,
@Default(true) bool progressBarIsChapterProgress,
@Default('\$bookTitle') String primaryTitle,
@Default('\$author') String secondaryTitle,
@Default(
[
NotificationMediaControl.rewind,
NotificationMediaControl.fastForward,
NotificationMediaControl.skipToPreviousChapter,
NotificationMediaControl.skipToNextChapter,
],
)
List<NotificationMediaControl> mediaControls,
}) = _NotificationSettings;
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
_$NotificationSettingsFromJson(json);
}
enum NotificationTitleType {
chapterTitle('chapterTitle'),
bookTitle('bookTitle'),
author('author'),
subtitle('subtitle'),
series('series'),
narrator('narrator'),
year('year');
const NotificationTitleType(this.stringValue);
final String stringValue;
}
enum NotificationMediaControl {
fastForward(Icons.fast_forward),
rewind(Icons.fast_rewind),
speedToggle(Icons.speed),
stop(Icons.stop),
skipToNextChapter(Icons.skip_next),
skipToPreviousChapter(Icons.skip_previous);
const NotificationMediaControl(this.icon);
final IconData icon;
}

View file

@ -23,6 +23,8 @@ mixin _$AppSettings {
ThemeSettings get themeSettings => throw _privateConstructorUsedError; ThemeSettings get themeSettings => throw _privateConstructorUsedError;
PlayerSettings get playerSettings => throw _privateConstructorUsedError; PlayerSettings get playerSettings => throw _privateConstructorUsedError;
DownloadSettings get downloadSettings => throw _privateConstructorUsedError; DownloadSettings get downloadSettings => throw _privateConstructorUsedError;
NotificationSettings get notificationSettings =>
throw _privateConstructorUsedError;
/// Serializes this AppSettings to a JSON map. /// Serializes this AppSettings to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -43,11 +45,13 @@ abstract class $AppSettingsCopyWith<$Res> {
$Res call( $Res call(
{ThemeSettings themeSettings, {ThemeSettings themeSettings,
PlayerSettings playerSettings, PlayerSettings playerSettings,
DownloadSettings downloadSettings}); DownloadSettings downloadSettings,
NotificationSettings notificationSettings});
$ThemeSettingsCopyWith<$Res> get themeSettings; $ThemeSettingsCopyWith<$Res> get themeSettings;
$PlayerSettingsCopyWith<$Res> get playerSettings; $PlayerSettingsCopyWith<$Res> get playerSettings;
$DownloadSettingsCopyWith<$Res> get downloadSettings; $DownloadSettingsCopyWith<$Res> get downloadSettings;
$NotificationSettingsCopyWith<$Res> get notificationSettings;
} }
/// @nodoc /// @nodoc
@ -68,6 +72,7 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
Object? themeSettings = null, Object? themeSettings = null,
Object? playerSettings = null, Object? playerSettings = null,
Object? downloadSettings = null, Object? downloadSettings = null,
Object? notificationSettings = null,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
themeSettings: null == themeSettings themeSettings: null == themeSettings
@ -82,6 +87,10 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
? _value.downloadSettings ? _value.downloadSettings
: downloadSettings // ignore: cast_nullable_to_non_nullable : downloadSettings // ignore: cast_nullable_to_non_nullable
as DownloadSettings, as DownloadSettings,
notificationSettings: null == notificationSettings
? _value.notificationSettings
: notificationSettings // ignore: cast_nullable_to_non_nullable
as NotificationSettings,
) as $Val); ) as $Val);
} }
@ -114,6 +123,17 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
return _then(_value.copyWith(downloadSettings: value) as $Val); return _then(_value.copyWith(downloadSettings: value) as $Val);
}); });
} }
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$NotificationSettingsCopyWith<$Res> get notificationSettings {
return $NotificationSettingsCopyWith<$Res>(_value.notificationSettings,
(value) {
return _then(_value.copyWith(notificationSettings: value) as $Val);
});
}
} }
/// @nodoc /// @nodoc
@ -127,7 +147,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res>
$Res call( $Res call(
{ThemeSettings themeSettings, {ThemeSettings themeSettings,
PlayerSettings playerSettings, PlayerSettings playerSettings,
DownloadSettings downloadSettings}); DownloadSettings downloadSettings,
NotificationSettings notificationSettings});
@override @override
$ThemeSettingsCopyWith<$Res> get themeSettings; $ThemeSettingsCopyWith<$Res> get themeSettings;
@ -135,6 +156,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res>
$PlayerSettingsCopyWith<$Res> get playerSettings; $PlayerSettingsCopyWith<$Res> get playerSettings;
@override @override
$DownloadSettingsCopyWith<$Res> get downloadSettings; $DownloadSettingsCopyWith<$Res> get downloadSettings;
@override
$NotificationSettingsCopyWith<$Res> get notificationSettings;
} }
/// @nodoc /// @nodoc
@ -153,6 +176,7 @@ class __$$AppSettingsImplCopyWithImpl<$Res>
Object? themeSettings = null, Object? themeSettings = null,
Object? playerSettings = null, Object? playerSettings = null,
Object? downloadSettings = null, Object? downloadSettings = null,
Object? notificationSettings = null,
}) { }) {
return _then(_$AppSettingsImpl( return _then(_$AppSettingsImpl(
themeSettings: null == themeSettings themeSettings: null == themeSettings
@ -167,6 +191,10 @@ class __$$AppSettingsImplCopyWithImpl<$Res>
? _value.downloadSettings ? _value.downloadSettings
: downloadSettings // ignore: cast_nullable_to_non_nullable : downloadSettings // ignore: cast_nullable_to_non_nullable
as DownloadSettings, as DownloadSettings,
notificationSettings: null == notificationSettings
? _value.notificationSettings
: notificationSettings // ignore: cast_nullable_to_non_nullable
as NotificationSettings,
)); ));
} }
} }
@ -177,7 +205,8 @@ class _$AppSettingsImpl implements _AppSettings {
const _$AppSettingsImpl( const _$AppSettingsImpl(
{this.themeSettings = const ThemeSettings(), {this.themeSettings = const ThemeSettings(),
this.playerSettings = const PlayerSettings(), this.playerSettings = const PlayerSettings(),
this.downloadSettings = const DownloadSettings()}); this.downloadSettings = const DownloadSettings(),
this.notificationSettings = const NotificationSettings()});
factory _$AppSettingsImpl.fromJson(Map<String, dynamic> json) => factory _$AppSettingsImpl.fromJson(Map<String, dynamic> json) =>
_$$AppSettingsImplFromJson(json); _$$AppSettingsImplFromJson(json);
@ -191,10 +220,13 @@ class _$AppSettingsImpl implements _AppSettings {
@override @override
@JsonKey() @JsonKey()
final DownloadSettings downloadSettings; final DownloadSettings downloadSettings;
@override
@JsonKey()
final NotificationSettings notificationSettings;
@override @override
String toString() { String toString() {
return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings)'; return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings, notificationSettings: $notificationSettings)';
} }
@override @override
@ -207,13 +239,15 @@ class _$AppSettingsImpl implements _AppSettings {
(identical(other.playerSettings, playerSettings) || (identical(other.playerSettings, playerSettings) ||
other.playerSettings == playerSettings) && other.playerSettings == playerSettings) &&
(identical(other.downloadSettings, downloadSettings) || (identical(other.downloadSettings, downloadSettings) ||
other.downloadSettings == downloadSettings)); other.downloadSettings == downloadSettings) &&
(identical(other.notificationSettings, notificationSettings) ||
other.notificationSettings == notificationSettings));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => int get hashCode => Object.hash(runtimeType, themeSettings, playerSettings,
Object.hash(runtimeType, themeSettings, playerSettings, downloadSettings); downloadSettings, notificationSettings);
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -235,7 +269,8 @@ abstract class _AppSettings implements AppSettings {
const factory _AppSettings( const factory _AppSettings(
{final ThemeSettings themeSettings, {final ThemeSettings themeSettings,
final PlayerSettings playerSettings, final PlayerSettings playerSettings,
final DownloadSettings downloadSettings}) = _$AppSettingsImpl; final DownloadSettings downloadSettings,
final NotificationSettings notificationSettings}) = _$AppSettingsImpl;
factory _AppSettings.fromJson(Map<String, dynamic> json) = factory _AppSettings.fromJson(Map<String, dynamic> json) =
_$AppSettingsImpl.fromJson; _$AppSettingsImpl.fromJson;
@ -246,6 +281,8 @@ abstract class _AppSettings implements AppSettings {
PlayerSettings get playerSettings; PlayerSettings get playerSettings;
@override @override
DownloadSettings get downloadSettings; DownloadSettings get downloadSettings;
@override
NotificationSettings get notificationSettings;
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -1935,3 +1972,293 @@ abstract class _DownloadSettings implements DownloadSettings {
_$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith => _$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
NotificationSettings _$NotificationSettingsFromJson(Map<String, dynamic> json) {
return _NotificationSettings.fromJson(json);
}
/// @nodoc
mixin _$NotificationSettings {
Duration get fastForwardInterval => throw _privateConstructorUsedError;
Duration get rewindInterval => throw _privateConstructorUsedError;
bool get progressBarIsChapterProgress => throw _privateConstructorUsedError;
String get primaryTitle => throw _privateConstructorUsedError;
String get secondaryTitle => throw _privateConstructorUsedError;
List<NotificationMediaControl> get mediaControls =>
throw _privateConstructorUsedError;
/// Serializes this NotificationSettings to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$NotificationSettingsCopyWith<NotificationSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NotificationSettingsCopyWith<$Res> {
factory $NotificationSettingsCopyWith(NotificationSettings value,
$Res Function(NotificationSettings) then) =
_$NotificationSettingsCopyWithImpl<$Res, NotificationSettings>;
@useResult
$Res call(
{Duration fastForwardInterval,
Duration rewindInterval,
bool progressBarIsChapterProgress,
String primaryTitle,
String secondaryTitle,
List<NotificationMediaControl> mediaControls});
}
/// @nodoc
class _$NotificationSettingsCopyWithImpl<$Res,
$Val extends NotificationSettings>
implements $NotificationSettingsCopyWith<$Res> {
_$NotificationSettingsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? fastForwardInterval = null,
Object? rewindInterval = null,
Object? progressBarIsChapterProgress = null,
Object? primaryTitle = null,
Object? secondaryTitle = null,
Object? mediaControls = null,
}) {
return _then(_value.copyWith(
fastForwardInterval: null == fastForwardInterval
? _value.fastForwardInterval
: fastForwardInterval // ignore: cast_nullable_to_non_nullable
as Duration,
rewindInterval: null == rewindInterval
? _value.rewindInterval
: rewindInterval // ignore: cast_nullable_to_non_nullable
as Duration,
progressBarIsChapterProgress: null == progressBarIsChapterProgress
? _value.progressBarIsChapterProgress
: progressBarIsChapterProgress // ignore: cast_nullable_to_non_nullable
as bool,
primaryTitle: null == primaryTitle
? _value.primaryTitle
: primaryTitle // ignore: cast_nullable_to_non_nullable
as String,
secondaryTitle: null == secondaryTitle
? _value.secondaryTitle
: secondaryTitle // ignore: cast_nullable_to_non_nullable
as String,
mediaControls: null == mediaControls
? _value.mediaControls
: mediaControls // ignore: cast_nullable_to_non_nullable
as List<NotificationMediaControl>,
) as $Val);
}
}
/// @nodoc
abstract class _$$NotificationSettingsImplCopyWith<$Res>
implements $NotificationSettingsCopyWith<$Res> {
factory _$$NotificationSettingsImplCopyWith(_$NotificationSettingsImpl value,
$Res Function(_$NotificationSettingsImpl) then) =
__$$NotificationSettingsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{Duration fastForwardInterval,
Duration rewindInterval,
bool progressBarIsChapterProgress,
String primaryTitle,
String secondaryTitle,
List<NotificationMediaControl> mediaControls});
}
/// @nodoc
class __$$NotificationSettingsImplCopyWithImpl<$Res>
extends _$NotificationSettingsCopyWithImpl<$Res, _$NotificationSettingsImpl>
implements _$$NotificationSettingsImplCopyWith<$Res> {
__$$NotificationSettingsImplCopyWithImpl(_$NotificationSettingsImpl _value,
$Res Function(_$NotificationSettingsImpl) _then)
: super(_value, _then);
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? fastForwardInterval = null,
Object? rewindInterval = null,
Object? progressBarIsChapterProgress = null,
Object? primaryTitle = null,
Object? secondaryTitle = null,
Object? mediaControls = null,
}) {
return _then(_$NotificationSettingsImpl(
fastForwardInterval: null == fastForwardInterval
? _value.fastForwardInterval
: fastForwardInterval // ignore: cast_nullable_to_non_nullable
as Duration,
rewindInterval: null == rewindInterval
? _value.rewindInterval
: rewindInterval // ignore: cast_nullable_to_non_nullable
as Duration,
progressBarIsChapterProgress: null == progressBarIsChapterProgress
? _value.progressBarIsChapterProgress
: progressBarIsChapterProgress // ignore: cast_nullable_to_non_nullable
as bool,
primaryTitle: null == primaryTitle
? _value.primaryTitle
: primaryTitle // ignore: cast_nullable_to_non_nullable
as String,
secondaryTitle: null == secondaryTitle
? _value.secondaryTitle
: secondaryTitle // ignore: cast_nullable_to_non_nullable
as String,
mediaControls: null == mediaControls
? _value._mediaControls
: mediaControls // ignore: cast_nullable_to_non_nullable
as List<NotificationMediaControl>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$NotificationSettingsImpl implements _NotificationSettings {
const _$NotificationSettingsImpl(
{this.fastForwardInterval = const Duration(seconds: 30),
this.rewindInterval = const Duration(seconds: 10),
this.progressBarIsChapterProgress = true,
this.primaryTitle = '\$bookTitle',
this.secondaryTitle = '\$author',
final List<NotificationMediaControl> mediaControls = const [
NotificationMediaControl.rewind,
NotificationMediaControl.fastForward,
NotificationMediaControl.skipToPreviousChapter,
NotificationMediaControl.skipToNextChapter
]})
: _mediaControls = mediaControls;
factory _$NotificationSettingsImpl.fromJson(Map<String, dynamic> json) =>
_$$NotificationSettingsImplFromJson(json);
@override
@JsonKey()
final Duration fastForwardInterval;
@override
@JsonKey()
final Duration rewindInterval;
@override
@JsonKey()
final bool progressBarIsChapterProgress;
@override
@JsonKey()
final String primaryTitle;
@override
@JsonKey()
final String secondaryTitle;
final List<NotificationMediaControl> _mediaControls;
@override
@JsonKey()
List<NotificationMediaControl> get mediaControls {
if (_mediaControls is EqualUnmodifiableListView) return _mediaControls;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_mediaControls);
}
@override
String toString() {
return 'NotificationSettings(fastForwardInterval: $fastForwardInterval, rewindInterval: $rewindInterval, progressBarIsChapterProgress: $progressBarIsChapterProgress, primaryTitle: $primaryTitle, secondaryTitle: $secondaryTitle, mediaControls: $mediaControls)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NotificationSettingsImpl &&
(identical(other.fastForwardInterval, fastForwardInterval) ||
other.fastForwardInterval == fastForwardInterval) &&
(identical(other.rewindInterval, rewindInterval) ||
other.rewindInterval == rewindInterval) &&
(identical(other.progressBarIsChapterProgress,
progressBarIsChapterProgress) ||
other.progressBarIsChapterProgress ==
progressBarIsChapterProgress) &&
(identical(other.primaryTitle, primaryTitle) ||
other.primaryTitle == primaryTitle) &&
(identical(other.secondaryTitle, secondaryTitle) ||
other.secondaryTitle == secondaryTitle) &&
const DeepCollectionEquality()
.equals(other._mediaControls, _mediaControls));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
fastForwardInterval,
rewindInterval,
progressBarIsChapterProgress,
primaryTitle,
secondaryTitle,
const DeepCollectionEquality().hash(_mediaControls));
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$NotificationSettingsImplCopyWith<_$NotificationSettingsImpl>
get copyWith =>
__$$NotificationSettingsImplCopyWithImpl<_$NotificationSettingsImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$NotificationSettingsImplToJson(
this,
);
}
}
abstract class _NotificationSettings implements NotificationSettings {
const factory _NotificationSettings(
{final Duration fastForwardInterval,
final Duration rewindInterval,
final bool progressBarIsChapterProgress,
final String primaryTitle,
final String secondaryTitle,
final List<NotificationMediaControl> mediaControls}) =
_$NotificationSettingsImpl;
factory _NotificationSettings.fromJson(Map<String, dynamic> json) =
_$NotificationSettingsImpl.fromJson;
@override
Duration get fastForwardInterval;
@override
Duration get rewindInterval;
@override
bool get progressBarIsChapterProgress;
@override
String get primaryTitle;
@override
String get secondaryTitle;
@override
List<NotificationMediaControl> get mediaControls;
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$NotificationSettingsImplCopyWith<_$NotificationSettingsImpl>
get copyWith => throw _privateConstructorUsedError;
}

View file

@ -20,6 +20,10 @@ _$AppSettingsImpl _$$AppSettingsImplFromJson(Map<String, dynamic> json) =>
? const DownloadSettings() ? const DownloadSettings()
: DownloadSettings.fromJson( : DownloadSettings.fromJson(
json['downloadSettings'] as Map<String, dynamic>), json['downloadSettings'] as Map<String, dynamic>),
notificationSettings: json['notificationSettings'] == null
? const NotificationSettings()
: NotificationSettings.fromJson(
json['notificationSettings'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
@ -27,6 +31,7 @@ Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
'themeSettings': instance.themeSettings, 'themeSettings': instance.themeSettings,
'playerSettings': instance.playerSettings, 'playerSettings': instance.playerSettings,
'downloadSettings': instance.downloadSettings, 'downloadSettings': instance.downloadSettings,
'notificationSettings': instance.notificationSettings,
}; };
_$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map<String, dynamic> json) => _$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map<String, dynamic> json) =>
@ -203,3 +208,50 @@ Map<String, dynamic> _$$DownloadSettingsImplToJson(
'maxConcurrentByHost': instance.maxConcurrentByHost, 'maxConcurrentByHost': instance.maxConcurrentByHost,
'maxConcurrentByGroup': instance.maxConcurrentByGroup, 'maxConcurrentByGroup': instance.maxConcurrentByGroup,
}; };
_$NotificationSettingsImpl _$$NotificationSettingsImplFromJson(
Map<String, dynamic> json) =>
_$NotificationSettingsImpl(
fastForwardInterval: json['fastForwardInterval'] == null
? const Duration(seconds: 30)
: Duration(
microseconds: (json['fastForwardInterval'] as num).toInt()),
rewindInterval: json['rewindInterval'] == null
? const Duration(seconds: 10)
: Duration(microseconds: (json['rewindInterval'] as num).toInt()),
progressBarIsChapterProgress:
json['progressBarIsChapterProgress'] as bool? ?? true,
primaryTitle: json['primaryTitle'] as String? ?? '\$bookTitle',
secondaryTitle: json['secondaryTitle'] as String? ?? '\$author',
mediaControls: (json['mediaControls'] as List<dynamic>?)
?.map((e) => $enumDecode(_$NotificationMediaControlEnumMap, e))
.toList() ??
const [
NotificationMediaControl.rewind,
NotificationMediaControl.fastForward,
NotificationMediaControl.skipToPreviousChapter,
NotificationMediaControl.skipToNextChapter
],
);
Map<String, dynamic> _$$NotificationSettingsImplToJson(
_$NotificationSettingsImpl instance) =>
<String, dynamic>{
'fastForwardInterval': instance.fastForwardInterval.inMicroseconds,
'rewindInterval': instance.rewindInterval.inMicroseconds,
'progressBarIsChapterProgress': instance.progressBarIsChapterProgress,
'primaryTitle': instance.primaryTitle,
'secondaryTitle': instance.secondaryTitle,
'mediaControls': instance.mediaControls
.map((e) => _$NotificationMediaControlEnumMap[e]!)
.toList(),
};
const _$NotificationMediaControlEnumMap = {
NotificationMediaControl.fastForward: 'fastForward',
NotificationMediaControl.rewind: 'rewind',
NotificationMediaControl.speedToggle: 'speedToggle',
NotificationMediaControl.stop: 'stop',
NotificationMediaControl.skipToNextChapter: 'skipToNextChapter',
NotificationMediaControl.skipToPreviousChapter: 'skipToPreviousChapter',
};

View file

@ -11,6 +11,7 @@ import 'package:vaani/api/server_provider.dart';
import 'package:vaani/router/router.dart'; import 'package:vaani/router/router.dart';
import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/models/app_settings.dart' as model; import 'package:vaani/settings/models/app_settings.dart' as model;
import 'package:vaani/settings/view/simple_settings_page.dart';
class AppSettingsPage extends HookConsumerWidget { class AppSettingsPage extends HookConsumerWidget {
const AppSettingsPage({ const AppSettingsPage({
@ -26,253 +27,272 @@ class AppSettingsPage extends HookConsumerWidget {
final serverURIController = useTextEditingController(); final serverURIController = useTextEditingController();
final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings;
return Scaffold( return SimpleSettingsPage(
appBar: AppBar( title: const Text('App Settings'),
title: const Text('App Settings'), sections: [
), // General section
body: SettingsList( SettingsSection(
sections: [ margin: const EdgeInsetsDirectional.symmetric(
// Appearance section horizontal: 16.0,
SettingsSection( vertical: 8.0,
margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'Appearance',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [
SettingsTile.switchTile(
initialValue: appSettings.themeSettings.isDarkMode,
title: const Text('Dark Mode'),
description: const Text('we all know dark mode is better'),
leading: appSettings.themeSettings.isDarkMode
? const Icon(Icons.dark_mode)
: const Icon(Icons.light_mode),
onToggle: (value) {
ref.read(appSettingsProvider.notifier).toggleDarkMode();
},
),
SettingsTile.switchTile(
initialValue:
appSettings.themeSettings.useMaterialThemeOnItemPage,
title: const Text('Adaptive Theme on Item Page'),
description: const Text(
'get fancy with the colors on the item page at the cost of some performance',
),
leading: appSettings.themeSettings.useMaterialThemeOnItemPage
? const Icon(Icons.auto_fix_high)
: const Icon(Icons.auto_fix_off),
onToggle: (value) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.themeSettings(
useMaterialThemeOnItemPage: value,
),
);
},
),
],
), ),
title: Text(
// Sleep Timer section 'General',
SettingsSection( style: Theme.of(context).textTheme.titleLarge,
margin: const EdgeInsetsDirectional.symmetric( ),
horizontal: 16.0, tiles: [
vertical: 8.0, 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);
},
), ),
title: Text( ],
'Sleep Timer', ),
style: Theme.of(context).textTheme.titleLarge, // Appearance section
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'Appearance',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [
SettingsTile.switchTile(
initialValue: appSettings.themeSettings.isDarkMode,
title: const Text('Dark Mode'),
description: const Text('we all know dark mode is better'),
leading: appSettings.themeSettings.isDarkMode
? const Icon(Icons.dark_mode)
: const Icon(Icons.light_mode),
onToggle: (value) {
ref.read(appSettingsProvider.notifier).toggleDarkMode();
},
), ),
tiles: [ SettingsTile.switchTile(
SettingsTile.navigation( initialValue:
// initialValue: sleepTimerSettings.autoTurnOnTimer, appSettings.themeSettings.useMaterialThemeOnItemPage,
title: const Text('Auto Turn On Timer'), title: const Text('Adaptive Theme on Item Page'),
description: const Text( description: const Text(
'Automatically turn on the sleep timer based on the time of day', 'get fancy with the colors on the item page at the cost of some performance',
), ),
leading: sleepTimerSettings.autoTurnOnTimer leading: appSettings.themeSettings.useMaterialThemeOnItemPage
? const Icon(Icons.timer) ? const Icon(Icons.auto_fix_high)
: const Icon(Icons.timer_off), : const Icon(Icons.auto_fix_off),
onPressed: (context) { onToggle: (value) {
// push the sleep timer settings page ref.read(appSettingsProvider.notifier).update(
context.pushNamed(Routes.autoSleepTimerSettings.name); appSettings.copyWith.themeSettings(
}, useMaterialThemeOnItemPage: value,
// 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, // Sleep Timer section
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'Sleep Timer',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [
SettingsTile.navigation(
// initialValue: sleepTimerSettings.autoTurnOnTimer,
title: const Text('Auto Turn On Timer'),
description: const Text(
'Automatically turn on the sleep timer based on the time of day',
),
leading: sleepTimerSettings.autoTurnOnTimer
? const Icon(Icons.timer)
: const Icon(Icons.timer_off),
onPressed: (context) {
// push the sleep timer settings page
context.pushNamed(Routes.autoSleepTimerSettings.name);
},
// a switch to enable or disable the auto turn off time
trailing: IntrinsicHeight(
child: Row(
children: [
VerticalDivider(
color: Theme.of(context).dividerColor.withOpacity(0.5),
indent: 8.0,
endIndent: 8.0,
// width: 8.0,
// thickness: 2.0,
// height: 24.0,
),
Switch(
value: sleepTimerSettings.autoTurnOnTimer,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.playerSettings
.sleepTimerSettings(
autoTurnOnTimer: value,
),
);
},
),
],
),
),
),
],
),
// Backup and Restore section
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'Backup and Restore',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [
SettingsTile(
title: const Text('Copy to Clipboard'),
leading: const Icon(Icons.copy),
description: const Text(
'Copy the app settings to the clipboard',
),
onPressed: (context) async {
// copy to clipboard
await Clipboard.setData(
ClipboardData(
text: jsonEncode(appSettings.toJson()),
),
);
// show toast
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings copied to clipboard'),
),
);
},
),
SettingsTile(
title: const Text('Restore'),
leading: const Icon(Icons.restore),
description: const Text(
'Restore the app settings from the backup',
),
onPressed: (context) {
final formKey = GlobalKey<FormState>();
// show a dialog to get the backup
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Restore Backup'),
content: Form(
key: formKey,
child: TextFormField(
controller: serverURIController,
decoration: const InputDecoration(
labelText: 'Backup',
hintText: 'Paste the backup here',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please paste the backup here';
}
return null;
},
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
if (formKey.currentState!.validate()) {
final backup = serverURIController.text;
final newSettings = model.AppSettings.fromJson(
// decode the backup as json
jsonDecode(backup),
);
ref
.read(appSettingsProvider.notifier)
.update(newSettings);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings restored'),
), ),
); );
}, // clear the backup
serverURIController.clear();
}
},
child: const Text('Restore'),
),
],
);
},
);
},
),
// a button to reset the app settings
SettingsTile(
title: const Text('Reset App Settings'),
leading: const Icon(Icons.settings_backup_restore),
description: const Text(
'Reset the app settings to the default values',
),
onPressed: (context) async {
// confirm the reset
final res = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Reset App Settings'),
content: const Text(
'Are you sure you want to reset the app settings?',
), ),
], actions: [
), TextButton(
), onPressed: () {
), Navigator.of(context).pop(false);
], },
), child: const Text('Cancel'),
// Backup and Restore section
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'Backup and Restore',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [
SettingsTile(
title: const Text('Copy to Clipboard'),
leading: const Icon(Icons.copy),
description: const Text(
'Copy the app settings to the clipboard',
),
onPressed: (context) async {
// copy to clipboard
await Clipboard.setData(
ClipboardData(
text: jsonEncode(appSettings.toJson()),
),
);
// show toast
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings copied to clipboard'),
),
);
},
),
SettingsTile(
title: const Text('Restore'),
leading: const Icon(Icons.restore),
description: const Text(
'Restore the app settings from the backup',
),
onPressed: (context) {
final formKey = GlobalKey<FormState>();
// show a dialog to get the backup
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Restore Backup'),
content: Form(
key: formKey,
child: TextFormField(
controller: serverURIController,
decoration: const InputDecoration(
labelText: 'Backup',
hintText: 'Paste the backup here',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please paste the backup here';
}
return null;
},
),
), ),
actions: [ TextButton(
TextButton( onPressed: () {
onPressed: () { Navigator.of(context).pop(true);
Navigator.of(context).pop(); },
}, child: const Text('Reset'),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
if (formKey.currentState!.validate()) {
final backup = serverURIController.text;
final newSettings = model.AppSettings.fromJson(
// decode the backup as json
jsonDecode(backup),
);
ref
.read(appSettingsProvider.notifier)
.update(newSettings);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings restored'),
),
);
// clear the backup
serverURIController.clear();
}
},
child: const Text('Restore'),
),
],
);
},
);
},
),
// a button to reset the app settings
SettingsTile(
title: const Text('Reset App Settings'),
leading: const Icon(Icons.settings_backup_restore),
description: const Text(
'Reset the app settings to the default values',
),
onPressed: (context) async {
// confirm the reset
final res = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Reset App Settings'),
content: const Text(
'Are you sure you want to reset the app settings?',
), ),
actions: [ ],
TextButton( );
onPressed: () { },
Navigator.of(context).pop(false); );
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Reset'),
),
],
);
},
);
// if the user confirms the reset // if the user confirms the reset
if (res == true) { if (res == true) {
ref.read(appSettingsProvider.notifier).reset(); ref.read(appSettingsProvider.notifier).reset();
} }
}, },
), ),
], ],
), ),
], ],
),
); );
} }
} }

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/view/simple_settings_page.dart';
import 'package:vaani/shared/extensions/time_of_day.dart'; import 'package:vaani/shared/extensions/time_of_day.dart';
class AutoSleepTimerSettingsPage extends HookConsumerWidget { class AutoSleepTimerSettingsPage extends HookConsumerWidget {
@ -14,97 +15,87 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget {
final appSettings = ref.watch(appSettingsProvider); final appSettings = ref.watch(appSettingsProvider);
final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings;
return Scaffold( return SimpleSettingsPage(
appBar: AppBar( title: const Text('Auto Sleep Timer Settings'),
title: const Text('Auto Sleep Timer Settings'), sections: [
), SettingsSection(
body: SettingsList( margin: const EdgeInsetsDirectional.symmetric(
sections: [ horizontal: 16.0,
SettingsSection( vertical: 8.0,
margin: const EdgeInsetsDirectional.symmetric( ),
horizontal: 16.0, tiles: [
vertical: 8.0, SettingsTile.switchTile(
// initialValue: sleepTimerSettings.autoTurnOnTimer,
title: const Text('Auto Turn On Timer'),
description: const Text(
'Automatically turn on the sleep timer based on the time of day',
),
leading: sleepTimerSettings.autoTurnOnTimer
? const Icon(Icons.timer)
: const Icon(Icons.timer_off),
onToggle: (value) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.playerSettings.sleepTimerSettings(
autoTurnOnTimer: value,
),
);
},
initialValue: sleepTimerSettings.autoTurnOnTimer,
), ),
tiles: [ // auto turn on time settings, enabled only when autoTurnOnTimer is enabled
SettingsTile.switchTile( SettingsTile.navigation(
// initialValue: sleepTimerSettings.autoTurnOnTimer, enabled: sleepTimerSettings.autoTurnOnTimer,
title: const Text('Auto Turn On Timer'), title: const Text('Auto Turn On Time'),
description: const Text( description: const Text(
'Automatically turn on the sleep timer based on the time of day', 'Turn on the sleep timer at the specified time',
), ),
leading: sleepTimerSettings.autoTurnOnTimer onPressed: (context) async {
? const Icon(Icons.timer) // navigate to the time picker
: const Icon(Icons.timer_off), final selected = await showTimePicker(
onToggle: (value) { context: context,
initialTime: sleepTimerSettings.autoTurnOnTime.toTimeOfDay(),
);
if (selected != null) {
ref.read(appSettingsProvider.notifier).update( ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.playerSettings.sleepTimerSettings( appSettings.copyWith.playerSettings.sleepTimerSettings(
autoTurnOnTimer: value, autoTurnOnTime: selected.toDuration(),
), ),
); );
}, }
initialValue: sleepTimerSettings.autoTurnOnTimer, },
value: Text(
sleepTimerSettings.autoTurnOnTime.toTimeOfDay().format(context),
), ),
// auto turn on time settings, enabled only when autoTurnOnTimer is enabled ),
SettingsTile.navigation( SettingsTile.navigation(
enabled: sleepTimerSettings.autoTurnOnTimer, title: const Text('Auto Turn Off Time'),
title: const Text('Auto Turn On Time'), description: const Text(
description: const Text( 'Turn off the sleep timer at the specified time',
'Turn on the sleep timer at the specified time',
),
onPressed: (context) async {
// navigate to the time picker
final selected = await showTimePicker(
context: context,
initialTime:
sleepTimerSettings.autoTurnOnTime.toTimeOfDay(),
);
if (selected != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.playerSettings
.sleepTimerSettings(
autoTurnOnTime: selected.toDuration(),
),
);
}
},
value: Text(
sleepTimerSettings.autoTurnOnTime
.toTimeOfDay()
.format(context),
),
), ),
SettingsTile.navigation( enabled: sleepTimerSettings.autoTurnOnTimer,
title: const Text('Auto Turn Off Time'), onPressed: (context) async {
description: const Text( // navigate to the time picker
'Turn off the sleep timer at the specified time', final selected = await showTimePicker(
), context: context,
enabled: sleepTimerSettings.autoTurnOnTimer, initialTime: sleepTimerSettings.autoTurnOffTime.toTimeOfDay(),
onPressed: (context) async { );
// navigate to the time picker if (selected != null) {
final selected = await showTimePicker( ref.read(appSettingsProvider.notifier).update(
context: context, appSettings.copyWith.playerSettings.sleepTimerSettings(
initialTime: autoTurnOffTime: selected.toDuration(),
sleepTimerSettings.autoTurnOffTime.toTimeOfDay(), ),
); );
if (selected != null) { }
ref.read(appSettingsProvider.notifier).update( },
appSettings.copyWith.playerSettings value: Text(
.sleepTimerSettings( sleepTimerSettings.autoTurnOffTime
autoTurnOffTime: selected.toDuration(), .toTimeOfDay()
), .format(context),
);
}
},
value: Text(
sleepTimerSettings.autoTurnOffTime
.toTimeOfDay()
.format(context),
),
), ),
], ),
), ],
], ),
), ],
); );
} }
} }

View file

@ -0,0 +1,404 @@
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/simple_settings_page.dart';
class NotificationSettingsPage extends HookConsumerWidget {
const NotificationSettingsPage({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettings = ref.watch(appSettingsProvider);
final notificationSettings = appSettings.notificationSettings;
return SimpleSettingsPage(
title: const Text('Notification Settings'),
sections: [
SettingsSection(
margin: const EdgeInsetsDirectional.only(
start: 16.0,
end: 16.0,
top: 8.0,
bottom: 8.0,
),
tiles: [
// set the primary and secondary titles
SettingsTile(
title: const Text('Primary Title'),
description: Text.rich(
TextSpan(
text: 'The title of the notification\n',
children: [
TextSpan(
text: notificationSettings.primaryTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
leading: const Icon(Icons.title),
onPressed: (context) async {
// show the notification title picker
final selectedTitle = await showDialog<String>(
context: context,
builder: (context) {
return NotificationTitlePicker(
initialValue: notificationSettings.primaryTitle,
title: 'Primary Title',
);
},
);
if (selectedTitle != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
primaryTitle: selectedTitle,
),
);
}
},
),
SettingsTile(
title: const Text('Secondary Title'),
description: Text.rich(
TextSpan(
text: 'The subtitle of the notification\n',
children: [
TextSpan(
text: notificationSettings.secondaryTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
leading: const Icon(Icons.title),
onPressed: (context) async {
// show the notification title picker
final selectedTitle = await showDialog<String>(
context: context,
builder: (context) {
return NotificationTitlePicker(
initialValue: notificationSettings.secondaryTitle,
title: 'Secondary Title',
);
},
);
if (selectedTitle != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
secondaryTitle: selectedTitle,
),
);
}
},
),
// set forward and backward intervals
SettingsTile(
title: const Text('Forward Interval'),
description: Row(
children: [
Text(
'${notificationSettings.fastForwardInterval.inSeconds} seconds',
),
Expanded(
child: TimeIntervalSlider(
defaultValue: notificationSettings.fastForwardInterval,
onChangedEnd: (interval) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
fastForwardInterval: interval,
),
);
},
),
),
],
),
leading: const Icon(Icons.fast_forward),
),
SettingsTile(
title: const Text('Backward Interval'),
description: Row(
children: [
Text(
'${notificationSettings.rewindInterval.inSeconds} seconds',
),
Expanded(
child: TimeIntervalSlider(
defaultValue: notificationSettings.rewindInterval,
onChangedEnd: (interval) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
rewindInterval: interval,
),
);
},
),
),
],
),
leading: const Icon(Icons.fast_rewind),
),
// set the media controls
SettingsTile(
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(),
),
],
),
onPressed: (context) async {
final selectedControls =
await showDialog<List<NotificationMediaControl>>(
context: context,
builder: (context) {
return MediaControlsPicker(
selectedControls: notificationSettings.mediaControls,
);
},
);
if (selectedControls != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
mediaControls: selectedControls,
),
);
}
},
),
// set the progress bar to show chapter progress
SettingsTile.switchTile(
title: const Text('Show Chapter Progress'),
leading: const Icon(Icons.book),
description:
const Text('instead of the overall progress of the book'),
initialValue: notificationSettings.progressBarIsChapterProgress,
onToggle: (value) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
progressBarIsChapterProgress: value,
),
);
},
),
],
),
],
);
}
}
class MediaControlsPicker extends HookConsumerWidget {
const MediaControlsPicker({
super.key,
required this.selectedControls,
});
final List<NotificationMediaControl> selectedControls;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedMediaControls = useState(selectedControls);
return AlertDialog(
title: const Text('Media Controls'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(selectedMediaControls.value);
},
child: const Text('OK'),
),
],
// a list of chips to easily select the media controls to display
// with icons and labels
content: Wrap(
spacing: 8.0,
children: NotificationMediaControl.values
.map(
(control) => ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(control.icon),
const SizedBox(width: 4.0),
Text(control.name),
],
),
selected: selectedMediaControls.value.contains(control),
onSelected: (selected) {
if (selected) {
selectedMediaControls.value = [
...selectedMediaControls.value,
control,
];
} else {
selectedMediaControls.value = [
...selectedMediaControls.value.where((c) => c != control),
];
}
},
),
)
.toList(),
),
);
}
}
class TimeIntervalSlider extends HookConsumerWidget {
const TimeIntervalSlider({
super.key,
this.title,
required this.defaultValue,
this.onChanged,
this.onChangedEnd,
this.min = const Duration(seconds: 5),
this.max = const Duration(seconds: 120),
this.step = const Duration(seconds: 5),
});
final Widget? title;
final Duration defaultValue;
final ValueChanged<Duration>? onChanged;
final ValueChanged<Duration>? onChangedEnd;
final Duration min;
final Duration max;
final Duration step;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedInterval = useState(defaultValue);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
title ?? const SizedBox.shrink(),
if (title != null) const SizedBox(height: 8.0),
Slider(
value: selectedInterval.value.inSeconds.toDouble(),
min: min.inSeconds.toDouble(),
max: max.inSeconds.toDouble(),
divisions: ((max.inSeconds - min.inSeconds) ~/ step.inSeconds),
label: '${selectedInterval.value.inSeconds} seconds',
onChanged: (value) {
selectedInterval.value = Duration(seconds: value.toInt());
onChanged?.call(selectedInterval.value);
},
onChangeEnd: (value) {
onChangedEnd?.call(selectedInterval.value);
},
),
],
);
}
}
class NotificationTitlePicker extends HookConsumerWidget {
const NotificationTitlePicker({
super.key,
required this.initialValue,
required this.title,
});
final String initialValue;
final String title;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedTitle = useState(initialValue);
final controller = useTextEditingController(text: initialValue);
return AlertDialog(
title: Text(title),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(selectedTitle.value);
},
child: const Text('OK'),
),
],
// a list of chips to easily insert available fields into the text field
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
autofocus: true,
controller: controller,
onChanged: (value) {
selectedTitle.value = value;
},
decoration: InputDecoration(
helper: const Text('Select a field below to insert it'),
suffix: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
selectedTitle.value = '';
},
),
),
),
const SizedBox(height: 8.0),
Wrap(
spacing: 8.0,
children: NotificationTitleType.values
.map(
(type) => ActionChip(
label: Text(type.stringValue),
onPressed: () {
final text = controller.text;
final newText = '$text\$${type.stringValue}';
controller.text = newText;
selectedTitle.value = newText;
},
),
)
.toList(),
),
],
),
);
}
}
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

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class SimpleSettingsPage extends HookConsumerWidget {
const SimpleSettingsPage({
super.key,
this.title,
this.sections,
});
final Widget? title;
final List<AbstractSettingsSection>? sections;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
// appBar: AppBar(
// title: title,
// ),
// body: body,
// an app bar which is bigger than the default app bar but on scroll shrinks to the default app bar with the title being animated
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200.0,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: title,
// background: Theme.of(context).primaryColor,
),
),
if (sections != null)
SliverList(
delegate: SliverChildListDelegate(
[
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: SettingsList(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
sections: sections!,
),
),
],
),
),
],
),
);
}
}

View file

@ -71,7 +71,7 @@ packages:
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
audio_service: audio_service:
dependency: transitive dependency: "direct main"
description: description:
name: audio_service name: audio_service
sha256: "9dd5ba7e77567b290c35908b1950d61485b4dfdd3a0ac398e98cfeec04651b75" sha256: "9dd5ba7e77567b290c35908b1950d61485b4dfdd3a0ac398e98cfeec04651b75"
@ -704,10 +704,11 @@ packages:
just_audio_background: just_audio_background:
dependency: "direct main" dependency: "direct main"
description: description:
name: just_audio_background path: just_audio_background
sha256: "7547b076d5445431c780b0915707f18baa6d9588077d5d8f811e8a77b4e0bec5" ref: media-notification-config
url: "https://pub.dev" resolved-ref: "79ac48a7d322d5b8db8847b35ed0c8555fa249bc"
source: hosted url: "https://github.com/Dr-Blank/just_audio"
source: git
version: "0.0.1-beta.13" version: "0.0.1-beta.13"
just_audio_media_kit: just_audio_media_kit:
dependency: "direct main" dependency: "direct main"

View file

@ -32,6 +32,7 @@ isar_version: &isar_version ^4.0.0-dev.13 # define the version to be used
dependencies: dependencies:
animated_list_plus: ^0.5.2 animated_list_plus: ^0.5.2
animated_theme_switcher: ^2.0.10 animated_theme_switcher: ^2.0.10
audio_service: ^0.18.15
audio_session: ^0.1.19 audio_session: ^0.1.19
audio_video_progress_bar: ^2.0.2 audio_video_progress_bar: ^2.0.2
auto_scroll_text: ^0.0.7 auto_scroll_text: ^0.0.7
@ -59,7 +60,12 @@ dependencies:
isar_flutter_libs: ^4.0.0-dev.13 isar_flutter_libs: ^4.0.0-dev.13
json_annotation: ^4.9.0 json_annotation: ^4.9.0
just_audio: ^0.9.37 just_audio: ^0.9.37
just_audio_background: ^0.0.1-beta.11 just_audio_background:
# TODO Remove git dep when https://github.com/ryanheise/just_audio/issues/912 is closed
git:
url: https://github.com/Dr-Blank/just_audio
ref: media-notification-config
path: just_audio_background
just_audio_media_kit: ^2.0.4 just_audio_media_kit: ^2.0.4
list_wheel_scroll_view_nls: ^0.0.3 list_wheel_scroll_view_nls: ^0.0.3
logging: ^1.2.0 logging: ^1.2.0