diff --git a/lib/components/AlbumScreen/ItemInfo.dart b/lib/components/AlbumScreen/ItemInfo.dart index 0bdc34470..17ae9d3ae 100644 --- a/lib/components/AlbumScreen/ItemInfo.dart +++ b/lib/components/AlbumScreen/ItemInfo.dart @@ -24,52 +24,78 @@ class ItemInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (item.type != "Playlist") _artistIconAndText(item, context), - _iconAndText(Icons.music_note, "${itemSongs.toString()} Songs"), - _iconAndText( - Icons.timer, - printDuration(Duration( + if (item.type != "Playlist") _ArtistIconAndText(album: item), + _IconAndText( + iconData: Icons.music_note, text: "${itemSongs.toString()} Songs"), + _IconAndText( + iconData: Icons.timer, + text: printDuration(Duration( microseconds: item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10))), if (item.type != "Playlist") - _iconAndText(Icons.event, item.productionYearString) + _IconAndText(iconData: Icons.event, text: item.productionYearString) ], ); } } -// TODO: Make this an actual widget instead of a function -Widget _iconAndText(IconData iconData, String text) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - iconData, - // Inactive icons have an opacity of 50% - // https://material.io/design/iconography/system-icons.html#color - color: Colors.white.withOpacity(0.5), - ), - const Padding(padding: EdgeInsets.symmetric(horizontal: 2)), - Expanded( - child: Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, +class _IconAndText extends StatelessWidget { + const _IconAndText({ + Key? key, + required this.iconData, + required this.text, + }) : super(key: key); + + final IconData iconData; + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + iconData, + // Inactive icons have an opacity of 50% with dark theme and 38% + // with bright theme + // https://material.io/design/iconography/system-icons.html#color + color: Theme.of(context).iconTheme.color?.withOpacity( + Theme.of(context).brightness == Brightness.light ? 0.38 : 0.5), ), - ) - ], - ), - ); + const Padding(padding: EdgeInsets.symmetric(horizontal: 2)), + Expanded( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + ], + ), + ); + } } -Widget _artistIconAndText(BaseItemDto album, BuildContext context) { - final jellyfinApiData = GetIt.instance(); - return GestureDetector( - onTap: () => jellyfinApiData.getItemById(album.albumArtists!.first.id).then( - (artist) => Navigator.of(context) - .pushNamed(ArtistScreen.routeName, arguments: artist)), - child: _iconAndText(Icons.person, processArtist(album.albumArtist)), - ); +class _ArtistIconAndText extends StatelessWidget { + const _ArtistIconAndText({Key? key, required this.album}) : super(key: key); + + final BaseItemDto album; + + @override + Widget build(BuildContext context) { + final jellyfinApiData = GetIt.instance(); + + return GestureDetector( + onTap: () => jellyfinApiData + .getItemById(album.albumArtists!.first.id) + .then((artist) => Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: artist)), + child: _IconAndText( + iconData: Icons.person, + text: processArtist(album.albumArtist), + ), + ); + } } diff --git a/lib/components/AlbumScreen/SongListTile.dart b/lib/components/AlbumScreen/SongListTile.dart index 654c84c24..63d9c57fa 100644 --- a/lib/components/AlbumScreen/SongListTile.dart +++ b/lib/components/AlbumScreen/SongListTile.dart @@ -73,27 +73,30 @@ class _SongListTileState extends State { stream: _audioHandler.mediaItem, builder: (context, snapshot) { return RichText( - text: TextSpan(children: [ - // third condition checks if the item is viewed from its album (instead of e.g. a playlist) - // same horrible check as in canGoToAlbum in GestureDetector below - if (mutableItem.indexNumber != null && - !widget.isSong && - mutableItem.albumId == widget.parentId) + text: TextSpan( + children: [ + // third condition checks if the item is viewed from its album (instead of e.g. a playlist) + // same horrible check as in canGoToAlbum in GestureDetector below + if (mutableItem.indexNumber != null && + !widget.isSong && + mutableItem.albumId == widget.parentId) + TextSpan( + text: mutableItem.indexNumber.toString() + ". ", + style: TextStyle(color: Theme.of(context).disabledColor)), TextSpan( - text: mutableItem.indexNumber.toString() + ". ", - style: TextStyle(color: Theme.of(context).disabledColor)), - TextSpan( - text: mutableItem.name ?? "Unknown Name", - style: TextStyle( - color: snapshot.data?.extras?["itemJson"]["Id"] == - mutableItem.id && - snapshot.data?.extras?["itemJson"]["AlbumId"] == - widget.parentId - ? Theme.of(context).colorScheme.secondary - : null, + text: mutableItem.name ?? "Unknown Name", + style: TextStyle( + color: snapshot.data?.extras?["itemJson"]["Id"] == + mutableItem.id && + snapshot.data?.extras?["itemJson"]["AlbumId"] == + widget.parentId + ? Theme.of(context).colorScheme.secondary + : null, + ), ), - ), - ], style: const TextStyle(fontSize: 16.0)), + ], + style: Theme.of(context).textTheme.subtitle1, + ), ); }, ), @@ -225,8 +228,8 @@ class _SongListTileState extends State { case SongListTileMenuItems.InstantMix: await _audioServiceHelper.startInstantMixForItem(mutableItem); - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text("Starting Instant Mix."))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Starting Instant Mix."))); break; case SongListTileMenuItems.GoToAlbum: late BaseItemDto album; diff --git a/lib/components/LayoutSettingsScreen/ThemeSelector.dart b/lib/components/LayoutSettingsScreen/ThemeSelector.dart new file mode 100644 index 000000000..7d10692f0 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/ThemeSelector.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +import '../../services/ThemeModeHelper.dart'; + +class ThemeSelector extends StatelessWidget { + const ThemeSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: ThemeModeHelper.themeModeListener, + builder: (_, box, __) { + return ListTile( + title: const Text("Theme"), + trailing: DropdownButton( + value: box.get("ThemeMode"), + items: ThemeMode.values + .map((e) => DropdownMenuItem( + child: Text( + e.name.replaceFirst( + e.name.characters.first, + e.name.characters.first.toUpperCase(), + ), + ), + value: e, + )) + .toList(), + onChanged: (value) { + if (value != null) { + ThemeModeHelper.setThemeMode(value); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/components/LogsScreen/LogTile.dart b/lib/components/LogsScreen/LogTile.dart index c5e6b9afd..7c8875281 100644 --- a/lib/components/LogsScreen/LogTile.dart +++ b/lib/components/LogsScreen/LogTile.dart @@ -28,7 +28,7 @@ class LogTile extends StatelessWidget { child: Card( color: _logColor(logRecord.level, context), child: ExpansionTile( - leading: _logIcon(logRecord.level, context), + leading: _LogIcon(level: logRecord.level), key: PageStorageKey(logRecord.time), title: RichText( maxLines: 3, @@ -55,54 +55,66 @@ class LogTile extends StatelessWidget { children: [ Text( "Message", - style: Theme.of(context).textTheme.headline5, + style: Theme.of(context).primaryTextTheme.headline5, ), - Text(logRecord.message), - // This empty bit of text adds some space between the message and trace - const Text(""), - Text("Stack Trace", style: Theme.of(context).textTheme.headline5), - Text(logRecord.stackTrace.toString()) + Text( + logRecord.message + "\n", + style: Theme.of(context).primaryTextTheme.bodyText2, + ), + Text( + "Stack Trace", + style: Theme.of(context).primaryTextTheme.headline5, + ), + Text( + logRecord.stackTrace.toString(), + style: Theme.of(context).primaryTextTheme.bodyText2, + ) ], ), ), ); } - Icon _logIcon(FinampLevel level, BuildContext context) { - Color? iconColor = Theme.of(context).iconTheme.color; + Color _logColor(FinampLevel level, BuildContext context) { + if (level == FinampLevel.WARNING) { + return Colors.orange; + } else if (level == FinampLevel.SEVERE) { + return Colors.red; + } + + return Theme.of(context).colorScheme.secondary; + } +} + +class _LogIcon extends StatelessWidget { + const _LogIcon({Key? key, required this.level}) : super(key: key); + + final FinampLevel level; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).primaryIconTheme.color; if (level == FinampLevel.INFO) { return Icon( Icons.info, - color: iconColor, + color: color, ); } else if (level == FinampLevel.WARNING) { return Icon( Icons.warning, - color: iconColor, + color: color, ); } else if (level == FinampLevel.SEVERE) { return Icon( Icons.error, - color: iconColor, - ); - } else { - return Icon( - Icons.info, - color: iconColor, + color: color, ); } - } - Color _logColor(FinampLevel level, BuildContext context) { - if (level == FinampLevel.INFO) { - return Colors.blue; - } else if (level == FinampLevel.WARNING) { - return Colors.orange; - } else if (level == FinampLevel.SEVERE) { - return Colors.red; - } else { - return Theme.of(context).cardColor; - } + return Icon( + Icons.info, + color: color, + ); } } diff --git a/lib/components/PlayerScreen/ProgressSlider.dart b/lib/components/PlayerScreen/ProgressSlider.dart index f7f6ca775..578dea1f3 100644 --- a/lib/components/PlayerScreen/ProgressSlider.dart +++ b/lib/components/PlayerScreen/ProgressSlider.dart @@ -54,36 +54,51 @@ class _ProgressSliderState extends State { // greyed out slider with some fake numbers. We also do this if // currentPosition is null, which sometimes happens when the app is // closed and reopened. - return widget.showPlaceholder ? Column( - children: [ - SliderTheme( - data: _sliderThemeData.copyWith( - trackShape: CustomTrackShape(), - ), - child: const Slider( - value: 0, - max: 1, - onChanged: null, - ), - ), - if (widget.showDuration) Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "00:00", - style: Theme.of(context).textTheme.bodyText2?.copyWith( - color: Theme.of(context).textTheme.caption?.color), - ), - Text( - "00:00", - style: Theme.of(context).textTheme.bodyText2?.copyWith( - color: Theme.of(context).textTheme.caption?.color), - ), - ], - ), - ], - ) : const SizedBox.shrink(); + return widget.showPlaceholder + ? Column( + children: [ + SliderTheme( + data: _sliderThemeData.copyWith( + trackShape: CustomTrackShape(), + ), + child: const Slider( + value: 0, + max: 1, + onChanged: null, + ), + ), + if (widget.showDuration) + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "00:00", + style: Theme.of(context) + .textTheme + .bodyText2 + ?.copyWith( + color: Theme.of(context) + .textTheme + .caption + ?.color), + ), + Text( + "00:00", + style: Theme.of(context) + .textTheme + .bodyText2 + ?.copyWith( + color: Theme.of(context) + .textTheme + .caption + ?.color), + ), + ], + ), + ], + ) + : const SizedBox.shrink(); } else if (snapshot.hasData) { return Column( mainAxisSize: MainAxisSize.min, @@ -91,62 +106,70 @@ class _ProgressSliderState extends State { Stack( children: [ // Slider displaying buffer status. - if (widget.showBuffer) SliderTheme( - data: _sliderThemeData.copyWith( - thumbShape: HiddenThumbComponentShape(), - activeTrackColor: generateMaterialColor( - Theme.of(context).primaryColor) - .shade300, - inactiveTrackColor: generateMaterialColor( - Theme.of(context).primaryColor) - .shade500, - trackShape: CustomTrackShape(), - ), - child: ExcludeSemantics( - child: Slider( - min: 0.0, - max: snapshot.data!.mediaItem?.duration == null - ? snapshot.data!.playbackState.bufferedPosition - .inMicroseconds - .toDouble() - : snapshot - .data!.mediaItem!.duration!.inMicroseconds - .toDouble(), - // We do this check to not show buffer status on - // downloaded songs. - value: snapshot.data!.mediaItem - ?.extras?["downloadedSongJson"] == - null - ? snapshot.data!.playbackState.bufferedPosition - .inMicroseconds - .clamp( - 0.0, - snapshot.data!.mediaItem!.duration == null - ? snapshot.data!.playbackState - .bufferedPosition.inMicroseconds - : snapshot.data!.mediaItem!.duration! - .inMicroseconds, - ) - .toDouble() - : 0, - onChanged: (_) {}, + if (widget.showBuffer) + SliderTheme( + data: _sliderThemeData.copyWith( + thumbShape: HiddenThumbComponentShape(), + activeTrackColor: generateMaterialColor( + Theme.of(context).primaryColor) + .shade300, + inactiveTrackColor: + Theme.of(context).brightness == Brightness.light + ? generateMaterialColor(Colors.grey).shade300 + : generateMaterialColor( + Theme.of(context).primaryColor) + .shade500, + trackShape: CustomTrackShape(), + ), + child: ExcludeSemantics( + child: Slider( + min: 0.0, + max: snapshot.data!.mediaItem?.duration == null + ? snapshot.data!.playbackState.bufferedPosition + .inMicroseconds + .toDouble() + : snapshot + .data!.mediaItem!.duration!.inMicroseconds + .toDouble(), + // We do this check to not show buffer status on + // downloaded songs. + value: snapshot.data!.mediaItem + ?.extras?["downloadedSongJson"] == + null + ? snapshot.data!.playbackState.bufferedPosition + .inMicroseconds + .clamp( + 0.0, + snapshot.data!.mediaItem!.duration == null + ? snapshot.data!.playbackState + .bufferedPosition.inMicroseconds + : snapshot.data!.mediaItem!.duration! + .inMicroseconds, + ) + .toDouble() + : 0, + onChanged: (_) {}, + ), ), ), - ), // Slider displaying playback progress. SliderTheme( - data: widget.allowSeeking ? _sliderThemeData.copyWith( - inactiveTrackColor: Colors.transparent, - trackShape: CustomTrackShape(), - ) : _sliderThemeData.copyWith( - inactiveTrackColor: Colors.transparent, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 0), - // gets rid of both horizontal and vertical padding - overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), - trackShape: const RectangularSliderTrackShape(), - // rectangular shape is thinner than round - trackHeight: 4.0, - ), + data: widget.allowSeeking + ? _sliderThemeData.copyWith( + inactiveTrackColor: Colors.transparent, + trackShape: CustomTrackShape(), + ) + : _sliderThemeData.copyWith( + inactiveTrackColor: Colors.transparent, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 0), + // gets rid of both horizontal and vertical padding + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 0), + trackShape: const RectangularSliderTrackShape(), + // rectangular shape is thinner than round + trackHeight: 4.0, + ), child: Slider( min: 0.0, max: snapshot.data!.mediaItem?.duration == null @@ -175,53 +198,60 @@ class _ProgressSliderState extends State { .data!.mediaItem!.duration!.inMicroseconds .toDouble()) .toDouble(), - onChanged: widget.allowSeeking ? (newValue) async { - // We don't actually tell audio_service to seek here - // because it would get flooded with seek requests - setState(() { - _dragValue = newValue; - }); - } : (_) {}, - onChangeStart: widget.allowSeeking ? (value) { - setState(() { - _dragValue = value; - }); - } : (_) {}, - onChangeEnd: widget.allowSeeking ? (newValue) async { - // Seek to the new position - await _audioHandler - .seek(Duration(microseconds: newValue.toInt())); + onChanged: widget.allowSeeking + ? (newValue) async { + // We don't actually tell audio_service to seek here + // because it would get flooded with seek requests + setState(() { + _dragValue = newValue; + }); + } + : (_) {}, + onChangeStart: widget.allowSeeking + ? (value) { + setState(() { + _dragValue = value; + }); + } + : (_) {}, + onChangeEnd: widget.allowSeeking + ? (newValue) async { + // Seek to the new position + await _audioHandler.seek( + Duration(microseconds: newValue.toInt())); - // Clear drag value so that the slider uses the play - // duration again. - setState(() { - _dragValue = null; - }); - } : (_) {}, + // Clear drag value so that the slider uses the play + // duration again. + setState(() { + _dragValue = null; + }); + } + : (_) {}, ), ), ], ), - if (widget.showDuration) Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - printDuration( - Duration( - microseconds: _dragValue?.toInt() ?? - snapshot.data!.position.inMicroseconds), + if (widget.showDuration) + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + printDuration( + Duration( + microseconds: _dragValue?.toInt() ?? + snapshot.data!.position.inMicroseconds), + ), + style: Theme.of(context).textTheme.bodyText2?.copyWith( + color: Theme.of(context).textTheme.caption?.color), ), - style: Theme.of(context).textTheme.bodyText2?.copyWith( - color: Theme.of(context).textTheme.caption?.color), - ), - Text( - printDuration(snapshot.data!.mediaItem?.duration), - style: Theme.of(context).textTheme.bodyText2?.copyWith( - color: Theme.of(context).textTheme.caption?.color), - ), - ], - ), + Text( + printDuration(snapshot.data!.mediaItem?.duration), + style: Theme.of(context).textTheme.bodyText2?.copyWith( + color: Theme.of(context).textTheme.caption?.color), + ), + ], + ), ], ); } else { diff --git a/lib/components/PlayerScreen/SongName.dart b/lib/components/PlayerScreen/SongName.dart index 00a231d8b..2fb15ea8d 100644 --- a/lib/components/PlayerScreen/SongName.dart +++ b/lib/components/PlayerScreen/SongName.dart @@ -19,6 +19,9 @@ class SongName extends StatelessWidget { final _audioHandler = GetIt.instance(); final jellyfinApiData = GetIt.instance(); + final textColour = + Theme.of(context).textTheme.bodyText2?.color?.withOpacity(0.6); + return StreamBuilder( stream: _audioHandler.mediaItem, builder: (context, snapshot) { @@ -31,7 +34,7 @@ class SongName extends StatelessWidget { songBaseItemDto.artistItems ?.map((e) => TextSpan( text: e.name, - style: TextStyle(color: Colors.white.withOpacity(0.6)), + style: TextStyle(color: textColour), recognizer: TapGestureRecognizer() ..onTap = () { // Offline artists aren't implemented yet so we return if @@ -47,7 +50,7 @@ class SongName extends StatelessWidget { separatedArtistTextSpans.add(artistTextSpan); separatedArtistTextSpans.add(TextSpan( text: ", ", - style: TextStyle(color: Colors.white.withOpacity(0.6)), + style: TextStyle(color: textColour), )); }); separatedArtistTextSpans.removeLast(); @@ -83,6 +86,9 @@ class SongNameContent extends StatelessWidget { Widget build(BuildContext context) { final jellyfinApiData = GetIt.instance(); + final textColour = + Theme.of(context).textTheme.bodyText2?.color?.withOpacity(0.6); + return Column(children: [ GestureDetector( onTap: songBaseItemDto == null @@ -93,7 +99,7 @@ class SongNameContent extends StatelessWidget { .popAndPushNamed(AlbumScreen.routeName, arguments: album)), child: Text( mediaItem == null ? "No Album" : mediaItem!.album ?? "No Album", - style: TextStyle(color: Colors.white.withOpacity(0.6)), + style: TextStyle(color: textColour), textAlign: TextAlign.center, ), ), @@ -113,7 +119,7 @@ class SongNameContent extends StatelessWidget { ? [ TextSpan( text: "No Artist", - style: TextStyle(color: Colors.white.withOpacity(0.6)), + style: TextStyle(color: textColour), ) ] : separatedArtistTextSpans, diff --git a/lib/main.dart b/lib/main.dart index d93a4e7d9..b0cbae1df 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,8 @@ import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import 'generateMaterialColor.dart'; +import 'models/ThemeModeAdapter.dart'; +import 'services/ThemeModeHelper.dart'; import 'setupLogging.dart'; import 'screens/UserSelector.dart'; import 'screens/MusicScreen.dart'; @@ -150,6 +152,7 @@ Future setupHive() async { Hive.registerAdapter(SortOrderAdapter()); Hive.registerAdapter(ContentViewTypeAdapter()); Hive.registerAdapter(DownloadedImageAdapter()); + Hive.registerAdapter(ThemeModeAdapter()); await Future.wait([ Hive.openBox("DownloadedParents"), Hive.openBox("DownloadedItems"), @@ -159,12 +162,17 @@ Future setupHive() async { Hive.openBox("FinampSettings"), Hive.openBox("DownloadedImages"), Hive.openBox("DownloadedImageIds"), + Hive.openBox("ThemeMode") ]); // If the settings box is empty, we add an initial settings value here. Box finampSettingsBox = Hive.box("FinampSettings"); if (finampSettingsBox.isEmpty) finampSettingsBox.put("FinampSettings", await FinampSettings.create()); + + // If no ThemeMode is set, we set it to the default (system) + Box themeModeBox = Hive.box("ThemeMode"); + if (themeModeBox.isEmpty) ThemeModeHelper.setThemeMode(ThemeMode.system); } Future _setupAudioServiceHelper() async { @@ -234,57 +242,80 @@ class Finamp extends StatelessWidget { FocusManager.instance.primaryFocus?.unfocus(); } }, - child: MaterialApp( - title: "Finamp", - routes: { - SplashScreen.routeName: (context) => const SplashScreen(), - UserSelector.routeName: (context) => const UserSelector(), - ViewSelector.routeName: (context) => const ViewSelector(), - MusicScreen.routeName: (context) => const MusicScreen(), - AlbumScreen.routeName: (context) => const AlbumScreen(), - ArtistScreen.routeName: (context) => const ArtistScreen(), - AddToPlaylistScreen.routeName: (context) => - const AddToPlaylistScreen(), - PlayerScreen.routeName: (context) => const PlayerScreen(), - DownloadsScreen.routeName: (context) => const DownloadsScreen(), - DownloadsErrorScreen.routeName: (context) => - const DownloadsErrorScreen(), - LogsScreen.routeName: (context) => const LogsScreen(), - SettingsScreen.routeName: (context) => const SettingsScreen(), - TranscodingSettingsScreen.routeName: (context) => - const TranscodingSettingsScreen(), - DownloadsSettingsScreen.routeName: (context) => - const DownloadsSettingsScreen(), - AddDownloadLocationScreen.routeName: (context) => - const AddDownloadLocationScreen(), - AudioServiceSettingsScreen.routeName: (context) => - const AudioServiceSettingsScreen(), - TabsSettingsScreen.routeName: (context) => const TabsSettingsScreen(), - LayoutSettingsScreen.routeName: (context) => - const LayoutSettingsScreen(), - }, - initialRoute: SplashScreen.routeName, - darkTheme: ThemeData( - brightness: Brightness.dark, - scaffoldBackgroundColor: backgroundColor, - appBarTheme: const AppBarTheme( - color: raisedDarkColor, - ), - cardColor: raisedDarkColor, - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: raisedDarkColor), - canvasColor: raisedDarkColor, - toggleableActiveColor: generateMaterialColor(accentColor).shade200, - visualDensity: VisualDensity.adaptivePlatformDensity, - colorScheme: ColorScheme.fromSwatch( - primarySwatch: generateMaterialColor(accentColor), - brightness: Brightness.dark, - accentColor: accentColor, - ), - indicatorColor: accentColor, - ), - themeMode: ThemeMode.dark, - ), + child: ValueListenableBuilder>( + valueListenable: ThemeModeHelper.themeModeListener, + builder: (_, box, __) { + return MaterialApp( + title: "Finamp", + routes: { + SplashScreen.routeName: (context) => const SplashScreen(), + UserSelector.routeName: (context) => const UserSelector(), + ViewSelector.routeName: (context) => const ViewSelector(), + MusicScreen.routeName: (context) => const MusicScreen(), + AlbumScreen.routeName: (context) => const AlbumScreen(), + ArtistScreen.routeName: (context) => const ArtistScreen(), + AddToPlaylistScreen.routeName: (context) => + const AddToPlaylistScreen(), + PlayerScreen.routeName: (context) => const PlayerScreen(), + DownloadsScreen.routeName: (context) => const DownloadsScreen(), + DownloadsErrorScreen.routeName: (context) => + const DownloadsErrorScreen(), + LogsScreen.routeName: (context) => const LogsScreen(), + SettingsScreen.routeName: (context) => const SettingsScreen(), + TranscodingSettingsScreen.routeName: (context) => + const TranscodingSettingsScreen(), + DownloadsSettingsScreen.routeName: (context) => + const DownloadsSettingsScreen(), + AddDownloadLocationScreen.routeName: (context) => + const AddDownloadLocationScreen(), + AudioServiceSettingsScreen.routeName: (context) => + const AudioServiceSettingsScreen(), + TabsSettingsScreen.routeName: (context) => + const TabsSettingsScreen(), + LayoutSettingsScreen.routeName: (context) => + const LayoutSettingsScreen(), + }, + initialRoute: SplashScreen.routeName, + theme: ThemeData( + colorScheme: ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accentColor), + brightness: Brightness.light, + accentColor: accentColor, + ), + appBarTheme: const AppBarTheme( + color: Colors.white, + foregroundColor: Colors.black, + systemOverlayStyle: const SystemUiOverlayStyle( + statusBarBrightness: Brightness.light), + ), + tabBarTheme: const TabBarTheme( + labelColor: Colors.black, + )), + darkTheme: ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: backgroundColor, + appBarTheme: const AppBarTheme( + color: raisedDarkColor, + systemOverlayStyle: const SystemUiOverlayStyle( + statusBarBrightness: Brightness.dark), + ), + cardColor: raisedDarkColor, + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: raisedDarkColor), + canvasColor: raisedDarkColor, + toggleableActiveColor: + generateMaterialColor(accentColor).shade200, + visualDensity: VisualDensity.adaptivePlatformDensity, + colorScheme: ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accentColor), + brightness: Brightness.dark, + accentColor: accentColor, + ), + indicatorColor: accentColor, + ), + themeMode: box.get("ThemeMode"), + ); + }), ); } } diff --git a/lib/models/FinampModels.dart b/lib/models/FinampModels.dart index 95f0662c9..18e300381 100644 --- a/lib/models/FinampModels.dart +++ b/lib/models/FinampModels.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:logging/logging.dart'; @@ -46,28 +47,29 @@ const _sleepTimerSeconds = 1800; // 30 Minutes @HiveType(typeId: 28) class FinampSettings { - FinampSettings( - {this.isOffline = false, - this.shouldTranscode = false, - this.transcodeBitrate = 320000, - // downloadLocations is required since the other values can be created with - // default values. create() is used to return a FinampSettings with - // downloadLocations. - required this.downloadLocations, - this.androidStopForegroundOnPause = true, - required this.showTabs, - this.isFavourite = false, - this.sortBy = SortBy.sortName, - this.sortOrder = SortOrder.ascending, - this.songShuffleItemCount = _songShuffleItemCountDefault, - this.contentViewType = _contentViewType, - this.contentGridViewCrossAxisCountPortrait = - _contentGridViewCrossAxisCountPortrait, - this.contentGridViewCrossAxisCountLandscape = - _contentGridViewCrossAxisCountLandscape, - this.showTextOnGridView = _showTextOnGridView, - this.sleepTimerSeconds = _sleepTimerSeconds, - required this.downloadLocationsMap}); + FinampSettings({ + this.isOffline = false, + this.shouldTranscode = false, + this.transcodeBitrate = 320000, + // downloadLocations is required since the other values can be created with + // default values. create() is used to return a FinampSettings with + // downloadLocations. + required this.downloadLocations, + this.androidStopForegroundOnPause = true, + required this.showTabs, + this.isFavourite = false, + this.sortBy = SortBy.sortName, + this.sortOrder = SortOrder.ascending, + this.songShuffleItemCount = _songShuffleItemCountDefault, + this.contentViewType = _contentViewType, + this.contentGridViewCrossAxisCountPortrait = + _contentGridViewCrossAxisCountPortrait, + this.contentGridViewCrossAxisCountLandscape = + _contentGridViewCrossAxisCountLandscape, + this.showTextOnGridView = _showTextOnGridView, + this.sleepTimerSeconds = _sleepTimerSeconds, + required this.downloadLocationsMap, + }); @HiveField(0) bool isOffline; diff --git a/lib/models/ThemeModeAdapter.dart b/lib/models/ThemeModeAdapter.dart new file mode 100644 index 000000000..db2f5c57d --- /dev/null +++ b/lib/models/ThemeModeAdapter.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +class ThemeModeAdapter extends TypeAdapter { + @override + final int typeId = 41; + + @override + ThemeMode read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return ThemeMode.system; + case 1: + return ThemeMode.light; + case 2: + return ThemeMode.dark; + default: + return ThemeMode.system; + } + } + + @override + void write(BinaryWriter writer, ThemeMode obj) { + switch (obj) { + case ThemeMode.system: + writer.writeByte(0); + break; + case ThemeMode.light: + writer.writeByte(1); + break; + case ThemeMode.dark: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ThemeModeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/screens/LayoutSettingsScreen.dart b/lib/screens/LayoutSettingsScreen.dart index bc3e0bd3e..8bbab1151 100644 --- a/lib/screens/LayoutSettingsScreen.dart +++ b/lib/screens/LayoutSettingsScreen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../components/LayoutSettingsScreen/ThemeSelector.dart'; import 'TabsSettingsScreen.dart'; import '../components/LayoutSettingsScreen/ContentGridViewCrossAxisCountListTile.dart'; import '../components/LayoutSettingsScreen/ContentViewTypeDropdownListTile.dart'; @@ -22,6 +23,7 @@ class LayoutSettingsScreen extends StatelessWidget { for (final type in ContentGridViewCrossAxisCountType.values) ContentGridViewCrossAxisCountListTile(type: type), const ShowTextOnGridViewSelector(), + const ThemeSelector(), const Divider(), ListTile( leading: const Icon(Icons.tab), diff --git a/lib/screens/SettingsScreen.dart b/lib/screens/SettingsScreen.dart index 4f8605c90..e73bcccb2 100644 --- a/lib/screens/SettingsScreen.dart +++ b/lib/screens/SettingsScreen.dart @@ -58,7 +58,7 @@ class SettingsScreen extends StatelessWidget { ), ListTile( leading: const Icon(Icons.widgets), - title: const Text("Layout"), + title: const Text("Layout & Theme"), onTap: () => Navigator.of(context) .pushNamed(LayoutSettingsScreen.routeName), ), diff --git a/lib/services/FinampSettingsHelper.dart b/lib/services/FinampSettingsHelper.dart index da9d90481..bdec426a7 100644 --- a/lib/services/FinampSettingsHelper.dart +++ b/lib/services/FinampSettingsHelper.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../models/FinampModels.dart'; diff --git a/lib/services/JellyfinApi.chopper.dart b/lib/services/JellyfinApi.chopper.dart index f89b27738..40479ef9c 100644 --- a/lib/services/JellyfinApi.chopper.dart +++ b/lib/services/JellyfinApi.chopper.dart @@ -95,6 +95,17 @@ class _$JellyfinApi extends JellyfinApi { responseConverter: JsonConverter.responseFactory); } + @override + Future getInstantMix( + {required String id, required String userId, required int limit}) { + final $url = '/Items/${id}/InstantMix'; + final $params = {'userId': userId, 'limit': limit}; + final $request = Request('GET', $url, client.baseUrl, parameters: $params); + return client.send($request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory); + } + @override Future getItemById( {required String userId, required String itemId}) { @@ -116,17 +127,6 @@ class _$JellyfinApi extends JellyfinApi { responseConverter: JsonConverter.responseFactory); } - @override - Future getInstantMix( - {required String id, required String userId, required int limit}) { - final $url = '/Items/${id}/InstantMix'; - final $params = {'UserId': userId, 'Limit': limit}; - final $request = Request('GET', $url, client.baseUrl, parameters: $params); - return client.send($request, - requestConverter: JsonConverter.requestFactory, - responseConverter: JsonConverter.responseFactory); - } - @override Future updateItem( {required String itemId, required BaseItemDto newItem}) { diff --git a/lib/services/ThemeModeHelper.dart b/lib/services/ThemeModeHelper.dart new file mode 100644 index 000000000..40396fd28 --- /dev/null +++ b/lib/services/ThemeModeHelper.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +/// A helper for setting the theme, like FinampSettingsHelper. We don't do theme +/// stuff in FinampSettingsHelper as we would have to rebuild the MaterialApp on +/// every setting change. +class ThemeModeHelper { + static ValueListenable> get themeModeListener => + Hive.box("ThemeMode").listenable(keys: ["ThemeMode"]); + + static void setThemeMode(ThemeMode themeMode) { + Hive.box("ThemeMode").put("ThemeMode", themeMode); + } +}