From 3cf0a0b1244f0ccd154153a05046a2f727a8ec31 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Wed, 25 Sep 2024 03:13:42 -0400 Subject: [PATCH] feat: extensive settings for media controls through notification (#28) * feat: add notification settings customisation options * feat: add notification settings page and update routing --- .../ic_stat_notification_logo.png | Bin 0 -> 1139 bytes .../ic_stat_notification_logo.png | Bin 0 -> 743 bytes .../ic_stat_notification_logo.png | Bin 0 -> 1741 bytes .../ic_stat_notification_logo.png | Bin 0 -> 2519 bytes .../ic_stat_notification_logo.png | Bin 0 -> 3869 bytes .../player/core/audiobook_player.dart | 51 +- lib/features/player/core/init.dart | 62 +++ lib/main.dart | 21 +- lib/router/constants.dart | 9 +- lib/router/router.dart | 26 +- lib/settings/app_settings_provider.dart | 22 +- lib/settings/app_settings_provider.g.dart | 2 +- lib/settings/models/app_settings.dart | 52 ++ lib/settings/models/app_settings.freezed.dart | 343 +++++++++++- lib/settings/models/app_settings.g.dart | 52 ++ lib/settings/view/app_settings_page.dart | 494 +++++++++--------- .../view/auto_sleep_timer_settings_page.dart | 157 +++--- .../view/notification_settings_page.dart | 404 ++++++++++++++ lib/settings/view/simple_settings_page.dart | 53 ++ pubspec.lock | 11 +- pubspec.yaml | 8 +- 21 files changed, 1391 insertions(+), 376 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_stat_notification_logo.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_stat_notification_logo.png create mode 100644 lib/features/player/core/init.dart create mode 100644 lib/settings/view/notification_settings_page.dart create mode 100644 lib/settings/view/simple_settings_page.dart diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b2ccfefbb76bc96838fa03fd96fd0cab2bab31e4 GIT binary patch literal 1139 zcmV-(1dRKMP)9LGJ|jFL6k3z=jv!;F6q9%+aCBb8eu{f0hFA;0XqUjRS$e zmq@;_OTg}jo1G^h9CgP@F&cowXI6*QrBl9$+EDjCTxZy&Qg;a@WA`MSomZfdI4DAs zakkF}G?em51L%zl&OPatumFvutYgSA zh5hVtkVA`&NF%vx14^Jg7{?{pRUI9?tBR0-tUY4ao3f&@mbyGYK!WXubRf2}0@*t9 z3e-i@hYA526E%>n)7Bn<+G=``*41%3t+e?_q^zx?n7V01Oas=mmr~Yx3#F3q|4RSaL&TO}*c?!?aHN*`Hn4Z*YBh+mm z9eW+l5tC;i1FA7kO`o9Fqj_N#u|DUsNC#&sHn$^^7zR-RnNAtKMy{hEo6dIHJEK0E zcUt^=sn7HZBRju9`i!+5Xv0jR(+X>+0O|isx5}n&`;rr=FCb(3balOq?$c>sOY{oJ z>uQ_Dd+l9OCEq{>)MB2?P$r#+ejtg?4IY0f_lb(cFpd>Sw*Vahx)bS=ubZU6mIJh; z)`5(On0Mu^lr?q9s2M;y+oDb}MaTe>58ZQga4Ho5qiOV&Vg@pxhVeA7D=7fo@?P)vF2gt5 z^SlR&p+A-Cq`>ya9fxl&{q=AY->B=$CUBO(1q`S(f$7w}PzrNkFc3nZn0^~<$M!3! zz-a=D8L%}%pat7Ge0}DdaDnqo+>XG9umJtl$`s!>`Tp4=(rw0m8}`C7*aEe15AMP$ zxB#s-UR#nEY!UTCs6pUOoWN@w7vU@HfsHT>yP+O7z%3I#Os#mGjeiEv1=R@19+|;& z?APEkD2q+-6Li?eOh6gRUI(v20(a;)Bk(e*z%~NE*u-Z-0$TA9JaIBuOuG+(A8`U& zU5|pC3@+0?1W!W(Gw6?iuJBc;U}9yPDN%=9gm)&CNPqzqistZxcTi6E6g1)cZoWm- zx&&(#3i&XAKkW-JIETtVOpKjo)1JEm)9Al|HK6#ECa_rfmcT5~4O6B^@&9%ObW0jQ zw?{WZ-@$QE+5;>i*93;>>l?`w`zm%}&~@&BR$@{L$ZQcoYAaQ3i4N~Q41yk1%KSQq zkMwm@{DOTUm9Dk^pi9v0{zp+~Alq`d54y|RCjV9H!*f46`URM;gATSTSal{fHv`vb zlj*RpKtD8Un<8TLC#`_W@a+j=V}DZov!gtQk0~)w}4{ika{clQ+@c2P=aYJMIh|k^Qs}!aC4jrK_l{ zvX(>{*jDLFeg^s)=EG71)Segt6VdCw0S!#9$2ew5oPi1GHJ^|O0&mBhoq+!mNHSqK Z<}YeL8YrT5KlK0r002ovPDHLkV1ie0Sik@P literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png b/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..753dbe0c3a67ff72d54481eb14abdc23cb329f22 GIT binary patch literal 1741 zcmV;;1~U1HP)4!6^*#WD)`>w=r_Ao~98wv+Ik1rXzv=hRIn-U z?@LXHZ=HZ1?_kgaxfg08K!*~X1Wp8_>0mK+yYQ@=I{=(c-TWZepLaLmcoqfxi6_a2&Xd!6%2=m+0s~U}7Nw>U6cUz(6pU zHvRd2D9^71bafT#MPOo3Tm_tAQ;3H_Z!nI5Cj|o9>ZRZs>L!#Bc#3)>tgh4uJB9B( zczzzd29BsF0e$xs-~n(!1p+toZ4|f?Wg06G7{T|c1q5ED&5;QRoPo@v$QG3(n>Y!9 zyjh(fNd}r;1A)R7xRCaDfm|v9HU;w}2(G2ytLsG|?=8ABzkx`&XqMqlmLNIMla8=ATN6`;9j4 z0nNmA+PwL{jHOD2STe&I2fAkS9!b<(QW40H z5=_9DpqKHIcPxtJ4(JtT-ZAw!dNEz&CA9Ihm#VagKv)euhyl$*x~a!eD3r@#q6(z9 zrrtxoskd3~VB~*Jr9F*WOdyc(u1J&U20UeH&ykrV1U%U5!8}*&iQK1YmA73c1Of@e zzwXdKKinI`OQSE!dri0;nCE!CqA9hgWJVy6IFny(TTj&s1z*f-7dHNVyq^oqX-q-N z&bYO*$(n#sdNLRZR-&M3L9Rf+>v(s5$3 z)Y)JP_!aoiPSw*^5+d~=p!t|VP5`C^eSv3O|3~m(qKOTwl%OS1JTH?ufv^f4`So1u zuN&J8_ATIe9Z}xvluSxp2m}(InY~r>bY)yP9Lzv@)AP(qJqg&iCn?RwV#?rwzUF+V z65F~F2&K41w1yzI)f j)RjP{n#Oe2>IwV@0Tn%7>Ek}!00000NkvXXu0mjfd)6v$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fee76705b0746649b7198162f279ea5314479597 GIT binary patch literal 2519 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 literal 0 HcmV?d00001 diff --git a/lib/features/player/core/audiobook_player.dart b/lib/features/player/core/audiobook_player.dart index 106a547..6fe3bfa 100644 --- a/lib/features/player/core/audiobook_player.dart +++ b/lib/features/player/core/audiobook_player.dart @@ -8,6 +8,9 @@ import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; import 'package:logging/logging.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'); @@ -81,6 +84,7 @@ class AudiobookPlayer extends AudioPlayer { List? downloadedUris, Uri? artworkUri, }) async { + final appSettings = loadOrCreateAppSettings(); // if the book is null, stop the player if (book == null) { _book = null; @@ -128,8 +132,10 @@ class AudiobookPlayer extends AudioPlayer { // Specify a unique ID for each media item: id: book.libraryItemId + track.index.toString(), // Metadata to display in the notification: - album: book.metadata.title, - title: book.metadata.title ?? track.title, + title: appSettings.notificationSettings.primaryTitle + .formatNotificationTitle(book), + album: appSettings.notificationSettings.secondaryTitle + .formatNotificationTitle(book), artUri: artworkUri ?? Uri.parse( '$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800', @@ -198,7 +204,7 @@ class AudiobookPlayer extends AudioPlayer { @override Stream get positionStream { - // return the positioninbook stream + // return the positionInBook stream return super.positionStream.map((position) { if (_book == null) { return Duration.zero; @@ -267,3 +273,42 @@ Uri _getUri( return uri ?? 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; + } + } +} diff --git a/lib/features/player/core/init.dart b/lib/features/player/core/init.dart new file mode 100644 index 0000000..c61637b --- /dev/null +++ b/lib/features/player/core/init.dart @@ -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 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, + }, + ); + }, + ); +} diff --git a/lib/main.dart b/lib/main.dart index cbf2737..cea6f7f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,11 @@ -import 'package:audio_session/audio_session.dart'; import 'package:flutter/material.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:vaani/api/server_provider.dart'; import 'package:vaani/db/storage.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/player/core/init.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.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 await initStorage(); - // for configuring how this app will interact with other audio apps - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.speech()); + // initialize audio player + await configurePlayer(); - // 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 runApp( diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 5073e60..9dd15ad 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -28,9 +28,14 @@ class Routes { name: 'settings', ); static const autoSleepTimerSettings = _SimpleRoute( - pathName: 'autosleeptimer', + pathName: 'autoSleepTimer', name: 'autoSleepTimerSettings', - // parentRoute: settings, + parentRoute: settings, + ); + static const notificationSettings = _SimpleRoute( + pathName: 'notifications', + name: 'notificationSettings', + parentRoute: settings, ); // search and explore diff --git a/lib/router/router.dart b/lib/router/router.dart index b3085ee..8781e6f 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -12,6 +12,7 @@ import 'package:vaani/features/you/view/you_page.dart'; import 'package:vaani/pages/home_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/notification_settings_page.dart'; import 'scaffold_with_nav_bar.dart'; import 'transitions/slide.dart'; @@ -172,15 +173,22 @@ class MyAppRouter { name: Routes.settings.name, // builder: (context, state) => const AppSettingsPage(), pageBuilder: defaultPageBuilder(const AppSettingsPage()), - ), - GoRoute( - path: Routes.autoSleepTimerSettings.localPath, - name: Routes.autoSleepTimerSettings.name, - // builder: (context, state) => - // const AutoSleepTimerSettingsPage(), - pageBuilder: defaultPageBuilder( - const AutoSleepTimerSettingsPage(), - ), + routes: [ + GoRoute( + path: Routes.autoSleepTimerSettings.pathName, + name: Routes.autoSleepTimerSettings.name, + pageBuilder: defaultPageBuilder( + const AutoSleepTimerSettingsPage(), + ), + ), + GoRoute( + path: Routes.notificationSettings.pathName, + name: Routes.notificationSettings.name, + pageBuilder: defaultPageBuilder( + const NotificationSettingsPage(), + ), + ), + ], ), GoRoute( path: Routes.userManagement.localPath, diff --git a/lib/settings/app_settings_provider.dart b/lib/settings/app_settings_provider.dart index b23d18d..662a043 100644 --- a/lib/settings/app_settings_provider.dart +++ b/lib/settings/app_settings_provider.dart @@ -11,25 +11,29 @@ final _box = AvailableHiveBoxes.userPrefsBox; final _logger = Logger('AppSettingsProvider'); -model.AppSettings readFromBoxOrCreate() { +model.AppSettings loadOrCreateAppSettings() { // see if the settings are already in the box + model.AppSettings? settings; if (_box.isNotEmpty) { - final foundSettings = _box.getAt(0); - _logger.fine('found settings in box: $foundSettings'); - return foundSettings; + try { + settings = _box.getAt(0); + _logger.fine('found settings in box: $settings'); + } catch (e) { + _logger.warning('error reading settings from box: $e' + '\nclearing box'); + _box.clear(); + } } else { - // create a new settings object - const settings = model.AppSettings(); - _logger.fine('created new settings: $settings'); - return settings; + _logger.fine('no settings found in box, creating new settings'); } + return settings ?? const model.AppSettings(); } @Riverpod(keepAlive: true) class AppSettings extends _$AppSettings { @override model.AppSettings build() { - state = readFromBoxOrCreate(); + state = loadOrCreateAppSettings(); ref.listenSelf((_, __) { writeToBox(); }); diff --git a/lib/settings/app_settings_provider.g.dart b/lib/settings/app_settings_provider.g.dart index 4956783..2f321ee 100644 --- a/lib/settings/app_settings_provider.g.dart +++ b/lib/settings/app_settings_provider.g.dart @@ -6,7 +6,7 @@ part of 'app_settings_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$appSettingsHash() => r'e0e132b782b97f11d9791d4f1e45bf4ee67dd99b'; +String _$appSettingsHash() => r'f51d55f117692d4fb9f4b4febf02906c0953d334'; /// See also [AppSettings]. @ProviderFor(AppSettings) diff --git a/lib/settings/models/app_settings.dart b/lib/settings/models/app_settings.dart index 0591e9f..cc4506b 100644 --- a/lib/settings/models/app_settings.dart +++ b/lib/settings/models/app_settings.dart @@ -1,5 +1,6 @@ // a freezed class to store the settings of the app +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'app_settings.freezed.dart'; @@ -14,6 +15,7 @@ class AppSettings with _$AppSettings { @Default(ThemeSettings()) ThemeSettings themeSettings, @Default(PlayerSettings()) PlayerSettings playerSettings, @Default(DownloadSettings()) DownloadSettings downloadSettings, + @Default(NotificationSettings()) NotificationSettings notificationSettings, }) = _AppSettings; factory AppSettings.fromJson(Map json) => @@ -133,3 +135,53 @@ class DownloadSettings with _$DownloadSettings { factory DownloadSettings.fromJson(Map 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 mediaControls, + }) = _NotificationSettings; + + factory NotificationSettings.fromJson(Map 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; +} diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index 90d2cd2..204d314 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -23,6 +23,8 @@ mixin _$AppSettings { ThemeSettings get themeSettings => throw _privateConstructorUsedError; PlayerSettings get playerSettings => throw _privateConstructorUsedError; DownloadSettings get downloadSettings => throw _privateConstructorUsedError; + NotificationSettings get notificationSettings => + throw _privateConstructorUsedError; /// Serializes this AppSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -43,11 +45,13 @@ abstract class $AppSettingsCopyWith<$Res> { $Res call( {ThemeSettings themeSettings, PlayerSettings playerSettings, - DownloadSettings downloadSettings}); + DownloadSettings downloadSettings, + NotificationSettings notificationSettings}); $ThemeSettingsCopyWith<$Res> get themeSettings; $PlayerSettingsCopyWith<$Res> get playerSettings; $DownloadSettingsCopyWith<$Res> get downloadSettings; + $NotificationSettingsCopyWith<$Res> get notificationSettings; } /// @nodoc @@ -68,6 +72,7 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> Object? themeSettings = null, Object? playerSettings = null, Object? downloadSettings = null, + Object? notificationSettings = null, }) { return _then(_value.copyWith( themeSettings: null == themeSettings @@ -82,6 +87,10 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> ? _value.downloadSettings : downloadSettings // ignore: cast_nullable_to_non_nullable as DownloadSettings, + notificationSettings: null == notificationSettings + ? _value.notificationSettings + : notificationSettings // ignore: cast_nullable_to_non_nullable + as NotificationSettings, ) as $Val); } @@ -114,6 +123,17 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> 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 @@ -127,7 +147,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res> $Res call( {ThemeSettings themeSettings, PlayerSettings playerSettings, - DownloadSettings downloadSettings}); + DownloadSettings downloadSettings, + NotificationSettings notificationSettings}); @override $ThemeSettingsCopyWith<$Res> get themeSettings; @@ -135,6 +156,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res> $PlayerSettingsCopyWith<$Res> get playerSettings; @override $DownloadSettingsCopyWith<$Res> get downloadSettings; + @override + $NotificationSettingsCopyWith<$Res> get notificationSettings; } /// @nodoc @@ -153,6 +176,7 @@ class __$$AppSettingsImplCopyWithImpl<$Res> Object? themeSettings = null, Object? playerSettings = null, Object? downloadSettings = null, + Object? notificationSettings = null, }) { return _then(_$AppSettingsImpl( themeSettings: null == themeSettings @@ -167,6 +191,10 @@ class __$$AppSettingsImplCopyWithImpl<$Res> ? _value.downloadSettings : downloadSettings // ignore: cast_nullable_to_non_nullable 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( {this.themeSettings = const ThemeSettings(), this.playerSettings = const PlayerSettings(), - this.downloadSettings = const DownloadSettings()}); + this.downloadSettings = const DownloadSettings(), + this.notificationSettings = const NotificationSettings()}); factory _$AppSettingsImpl.fromJson(Map json) => _$$AppSettingsImplFromJson(json); @@ -191,10 +220,13 @@ class _$AppSettingsImpl implements _AppSettings { @override @JsonKey() final DownloadSettings downloadSettings; + @override + @JsonKey() + final NotificationSettings notificationSettings; @override String toString() { - return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings)'; + return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings, notificationSettings: $notificationSettings)'; } @override @@ -207,13 +239,15 @@ class _$AppSettingsImpl implements _AppSettings { (identical(other.playerSettings, playerSettings) || other.playerSettings == playerSettings) && (identical(other.downloadSettings, downloadSettings) || - other.downloadSettings == downloadSettings)); + other.downloadSettings == downloadSettings) && + (identical(other.notificationSettings, notificationSettings) || + other.notificationSettings == notificationSettings)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, themeSettings, playerSettings, downloadSettings); + int get hashCode => Object.hash(runtimeType, themeSettings, playerSettings, + downloadSettings, notificationSettings); /// Create a copy of AppSettings /// with the given fields replaced by the non-null parameter values. @@ -235,7 +269,8 @@ abstract class _AppSettings implements AppSettings { const factory _AppSettings( {final ThemeSettings themeSettings, final PlayerSettings playerSettings, - final DownloadSettings downloadSettings}) = _$AppSettingsImpl; + final DownloadSettings downloadSettings, + final NotificationSettings notificationSettings}) = _$AppSettingsImpl; factory _AppSettings.fromJson(Map json) = _$AppSettingsImpl.fromJson; @@ -246,6 +281,8 @@ abstract class _AppSettings implements AppSettings { PlayerSettings get playerSettings; @override DownloadSettings get downloadSettings; + @override + NotificationSettings get notificationSettings; /// Create a copy of AppSettings /// with the given fields replaced by the non-null parameter values. @@ -1935,3 +1972,293 @@ abstract class _DownloadSettings implements DownloadSettings { _$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith => throw _privateConstructorUsedError; } + +NotificationSettings _$NotificationSettingsFromJson(Map 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 get mediaControls => + throw _privateConstructorUsedError; + + /// Serializes this NotificationSettings to a JSON map. + Map 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 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 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, + ) 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 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, + )); + } +} + +/// @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 mediaControls = const [ + NotificationMediaControl.rewind, + NotificationMediaControl.fastForward, + NotificationMediaControl.skipToPreviousChapter, + NotificationMediaControl.skipToNextChapter + ]}) + : _mediaControls = mediaControls; + + factory _$NotificationSettingsImpl.fromJson(Map 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 _mediaControls; + @override + @JsonKey() + List 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 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 mediaControls}) = + _$NotificationSettingsImpl; + + factory _NotificationSettings.fromJson(Map 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 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; +} diff --git a/lib/settings/models/app_settings.g.dart b/lib/settings/models/app_settings.g.dart index 8986989..58aaad4 100644 --- a/lib/settings/models/app_settings.g.dart +++ b/lib/settings/models/app_settings.g.dart @@ -20,6 +20,10 @@ _$AppSettingsImpl _$$AppSettingsImplFromJson(Map json) => ? const DownloadSettings() : DownloadSettings.fromJson( json['downloadSettings'] as Map), + notificationSettings: json['notificationSettings'] == null + ? const NotificationSettings() + : NotificationSettings.fromJson( + json['notificationSettings'] as Map), ); Map _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => @@ -27,6 +31,7 @@ Map _$$AppSettingsImplToJson(_$AppSettingsImpl instance) => 'themeSettings': instance.themeSettings, 'playerSettings': instance.playerSettings, 'downloadSettings': instance.downloadSettings, + 'notificationSettings': instance.notificationSettings, }; _$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map json) => @@ -203,3 +208,50 @@ Map _$$DownloadSettingsImplToJson( 'maxConcurrentByHost': instance.maxConcurrentByHost, 'maxConcurrentByGroup': instance.maxConcurrentByGroup, }; + +_$NotificationSettingsImpl _$$NotificationSettingsImplFromJson( + Map 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?) + ?.map((e) => $enumDecode(_$NotificationMediaControlEnumMap, e)) + .toList() ?? + const [ + NotificationMediaControl.rewind, + NotificationMediaControl.fastForward, + NotificationMediaControl.skipToPreviousChapter, + NotificationMediaControl.skipToNextChapter + ], + ); + +Map _$$NotificationSettingsImplToJson( + _$NotificationSettingsImpl instance) => + { + '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', +}; diff --git a/lib/settings/view/app_settings_page.dart b/lib/settings/view/app_settings_page.dart index 6cf2298..bca69fe 100644 --- a/lib/settings/view/app_settings_page.dart +++ b/lib/settings/view/app_settings_page.dart @@ -11,6 +11,7 @@ import 'package:vaani/api/server_provider.dart'; 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'; class AppSettingsPage extends HookConsumerWidget { const AppSettingsPage({ @@ -26,253 +27,272 @@ class AppSettingsPage extends HookConsumerWidget { final serverURIController = useTextEditingController(); final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; - return Scaffold( - appBar: AppBar( - title: const Text('App Settings'), - ), - body: SettingsList( - sections: [ - // 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(); - }, - ), - 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, - ), - ); - }, - ), - ], + return SimpleSettingsPage( + title: const Text('App Settings'), + sections: [ + // General section + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, ), - - // Sleep Timer 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); + }, ), - 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.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, + 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, ), - 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(); + // 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?', ), - ], - ), - ), - ), - ], - ), - - // 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(); - // 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(false); + }, + child: const Text('Cancel'), ), - 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?', + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text('Reset'), ), - 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 (res == true) { - ref.read(appSettingsProvider.notifier).reset(); - } - }, - ), - ], - ), - ], - ), + // if the user confirms the reset + if (res == true) { + ref.read(appSettingsProvider.notifier).reset(); + } + }, + ), + ], + ), + ], ); } } diff --git a/lib/settings/view/auto_sleep_timer_settings_page.dart b/lib/settings/view/auto_sleep_timer_settings_page.dart index 2383ba1..29524a9 100644 --- a/lib/settings/view/auto_sleep_timer_settings_page.dart +++ b/lib/settings/view/auto_sleep_timer_settings_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.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/view/simple_settings_page.dart'; import 'package:vaani/shared/extensions/time_of_day.dart'; class AutoSleepTimerSettingsPage extends HookConsumerWidget { @@ -14,97 +15,87 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget { final appSettings = ref.watch(appSettingsProvider); final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings; - return Scaffold( - appBar: AppBar( - title: const Text('Auto Sleep Timer Settings'), - ), - body: SettingsList( - sections: [ - SettingsSection( - margin: const EdgeInsetsDirectional.symmetric( - horizontal: 16.0, - vertical: 8.0, + return SimpleSettingsPage( + title: const Text('Auto Sleep Timer Settings'), + sections: [ + SettingsSection( + margin: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + tiles: [ + SettingsTile.switchTile( + // initialValue: sleepTimerSettings.autoTurnOnTimer, + title: const Text('Auto Turn On Timer'), + description: const Text( + 'Automatically turn on the sleep timer based on the time of day', + ), + leading: sleepTimerSettings.autoTurnOnTimer + ? const Icon(Icons.timer) + : const Icon(Icons.timer_off), + onToggle: (value) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings.sleepTimerSettings( + autoTurnOnTimer: value, + ), + ); + }, + initialValue: sleepTimerSettings.autoTurnOnTimer, ), - tiles: [ - SettingsTile.switchTile( - // initialValue: sleepTimerSettings.autoTurnOnTimer, - title: const Text('Auto Turn On Timer'), - description: const Text( - 'Automatically turn on the sleep timer based on the time of day', - ), - leading: sleepTimerSettings.autoTurnOnTimer - ? const Icon(Icons.timer) - : const Icon(Icons.timer_off), - onToggle: (value) { + // auto turn on time settings, enabled only when autoTurnOnTimer is enabled + SettingsTile.navigation( + enabled: sleepTimerSettings.autoTurnOnTimer, + title: const Text('Auto Turn On Time'), + description: const Text( + 'Turn on the sleep timer at the specified time', + ), + onPressed: (context) async { + // navigate to the time picker + final selected = await showTimePicker( + context: context, + initialTime: sleepTimerSettings.autoTurnOnTime.toTimeOfDay(), + ); + if (selected != null) { ref.read(appSettingsProvider.notifier).update( 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( - enabled: sleepTimerSettings.autoTurnOnTimer, - title: const Text('Auto Turn On Time'), - description: const Text( - 'Turn on the sleep timer at the specified time', - ), - onPressed: (context) async { - // navigate to the time picker - final selected = await showTimePicker( - context: context, - initialTime: - sleepTimerSettings.autoTurnOnTime.toTimeOfDay(), - ); - if (selected != null) { - ref.read(appSettingsProvider.notifier).update( - appSettings.copyWith.playerSettings - .sleepTimerSettings( - autoTurnOnTime: selected.toDuration(), - ), - ); - } - }, - value: Text( - sleepTimerSettings.autoTurnOnTime - .toTimeOfDay() - .format(context), - ), + ), + SettingsTile.navigation( + title: const Text('Auto Turn Off Time'), + description: const Text( + 'Turn off the sleep timer at the specified time', ), - SettingsTile.navigation( - title: const Text('Auto Turn Off Time'), - description: const Text( - 'Turn off the sleep timer at the specified time', - ), - enabled: sleepTimerSettings.autoTurnOnTimer, - onPressed: (context) async { - // navigate to the time picker - final selected = await showTimePicker( - context: context, - initialTime: - sleepTimerSettings.autoTurnOffTime.toTimeOfDay(), - ); - if (selected != null) { - ref.read(appSettingsProvider.notifier).update( - appSettings.copyWith.playerSettings - .sleepTimerSettings( - autoTurnOffTime: selected.toDuration(), - ), - ); - } - }, - value: Text( - sleepTimerSettings.autoTurnOffTime - .toTimeOfDay() - .format(context), - ), + enabled: sleepTimerSettings.autoTurnOnTimer, + onPressed: (context) async { + // navigate to the time picker + final selected = await showTimePicker( + context: context, + initialTime: sleepTimerSettings.autoTurnOffTime.toTimeOfDay(), + ); + if (selected != null) { + ref.read(appSettingsProvider.notifier).update( + appSettings.copyWith.playerSettings.sleepTimerSettings( + autoTurnOffTime: selected.toDuration(), + ), + ); + } + }, + value: Text( + sleepTimerSettings.autoTurnOffTime + .toTimeOfDay() + .format(context), ), - ], - ), - ], - ), + ), + ], + ), + ], ); } } diff --git a/lib/settings/view/notification_settings_page.dart b/lib/settings/view/notification_settings_page.dart new file mode 100644 index 0000000..df1c5e5 --- /dev/null +++ b/lib/settings/view/notification_settings_page.dart @@ -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( + 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( + 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>( + 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 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? onChanged; + final ValueChanged? 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 showNotificationTitlePicker( + BuildContext context, { + required String initialValue, + required String title, +}) async { + return showDialog( + context: context, + builder: (context) { + return NotificationTitlePicker(initialValue: initialValue, title: title); + }, + ); +} diff --git a/lib/settings/view/simple_settings_page.dart b/lib/settings/view/simple_settings_page.dart new file mode 100644 index 0000000..586e986 --- /dev/null +++ b/lib/settings/view/simple_settings_page.dart @@ -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? 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!, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2fe54a5..f7059ef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -71,7 +71,7 @@ packages: source: hosted version: "2.11.0" audio_service: - dependency: transitive + dependency: "direct main" description: name: audio_service sha256: "9dd5ba7e77567b290c35908b1950d61485b4dfdd3a0ac398e98cfeec04651b75" @@ -704,10 +704,11 @@ packages: just_audio_background: dependency: "direct main" description: - name: just_audio_background - sha256: "7547b076d5445431c780b0915707f18baa6d9588077d5d8f811e8a77b4e0bec5" - url: "https://pub.dev" - source: hosted + path: just_audio_background + ref: media-notification-config + resolved-ref: "79ac48a7d322d5b8db8847b35ed0c8555fa249bc" + url: "https://github.com/Dr-Blank/just_audio" + source: git version: "0.0.1-beta.13" just_audio_media_kit: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index 17f7b22..888f8b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ isar_version: &isar_version ^4.0.0-dev.13 # define the version to be used dependencies: animated_list_plus: ^0.5.2 animated_theme_switcher: ^2.0.10 + audio_service: ^0.18.15 audio_session: ^0.1.19 audio_video_progress_bar: ^2.0.2 auto_scroll_text: ^0.0.7 @@ -59,7 +60,12 @@ dependencies: isar_flutter_libs: ^4.0.0-dev.13 json_annotation: ^4.9.0 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 list_wheel_scroll_view_nls: ^0.0.3 logging: ^1.2.0