diff --git a/assets/fonts/Inter-LICENSE.txt b/assets/fonts/Inter-LICENSE.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/assets/fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/InterVariable.ttf b/assets/fonts/InterVariable.ttf new file mode 100644 index 00000000..4ab79e01 Binary files /dev/null and b/assets/fonts/InterVariable.ttf differ diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2301b63b..349b9083 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,12 @@ { + "nowPlayingVolume": "Volume", + "@nowPlayingVolume": { + "description": "System volume control label in the Mornye player" + }, + "appearanceStyle": "App Style", + "@appearanceStyle": { + "description": "Appearance section for choosing the app visual style" + }, "discordRichPresence": "Discord Rich Presence", "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", @@ -6601,6 +6609,9 @@ "@metadataCopyField": { "description": "Metadata menu action that copies the selected key and value" }, + "mornyeShowTabs": "Show tabs", + "mornyeSearch": "Search", + "mornyeContinuePlaying": "Continue Playing", "metadataCopyAll": "Copy all metadata", "@metadataCopyAll": { "description": "Metadata menu action that copies every visible key and value" @@ -6613,6 +6624,15 @@ "@optionsEmbeddedCoverSizeDescription": { "description": "Description shown in the embedded cover size picker" }, + "mornyeFavorite": "Favorite", + "mornyeGoToArtist": "Go to Artist", + "@mornyeFavorite": { + "description": "Compact context menu action that adds a track to Loved" + }, + "mornyeFavorited": "Favorited", + "@mornyeFavorited": { + "description": "Selected state of the compact favorite action" + }, "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index edad0b4e..16f4e6c3 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,12 @@ { + "nowPlayingVolume": "Volume", + "@nowPlayingVolume": { + "description": "Label kontrol volume sistem di pemutar Mornye" + }, + "appearanceStyle": "Gaya Aplikasi", + "@appearanceStyle": { + "description": "Bagian tampilan untuk memilih gaya visual aplikasi" + }, "discordRichPresence": "Discord Rich Presence", "discordPresenceDescription": "Bagikan lagu yang diputar di pemutar bawaan ke Discord. Discord harus terpasang dan sudah login.", "discordPresenceMissing": "Pasang Discord dan login, lalu matikan dan nyalakan kembali pengaturan ini.", @@ -6230,5 +6238,11 @@ "nowPlayingRepeatAll": "Repeat all", "nowPlayingRepeatOne": "Repeat one", "queueNetworkFailedOffline": "{count} downloads failed while offline", - "libraryFilterMetadataMissingIsrc": "Missing ISRC" + "libraryFilterMetadataMissingIsrc": "Missing ISRC", + "mornyeShowTabs": "Tampilkan tab", + "mornyeSearch": "Cari", + "mornyeContinuePlaying": "Putar Berikutnya", + "mornyeFavorite": "Favorit", + "mornyeGoToArtist": "Buka Artis", + "mornyeFavorited": "Difavoritkan" } diff --git a/lib/main.dart b/lib/main.dart index d8b6f61b..4c133297 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -37,6 +38,11 @@ void main() { runZonedGuarded( () async { WidgetsFlutterBinding.ensureInitialized(); + LicenseRegistry.addLicense(() async* { + yield LicenseEntryWithLineBreaks(const [ + 'Inter', + ], await rootBundle.loadString('assets/fonts/Inter-LICENSE.txt')); + }); _startupBenchmark?.observeFirstFrame(); final previousOnError = FlutterError.onError; diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index f2a6154c..46cc2f93 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -4,6 +4,15 @@ const String kThemeModeKey = 'theme_mode'; const String kUseDynamicColorKey = 'use_dynamic_color'; const String kSeedColorKey = 'seed_color'; const String kUseAmoledKey = 'use_amoled'; +const String kThemeStyleKey = 'theme_style'; + +enum AppThemeStyle { material, mornye } + +AppThemeStyle themeStyleFromString(String? value) => + AppThemeStyle.values.firstWhere( + (style) => style.name == value, + orElse: () => AppThemeStyle.material, + ); /// Default Spotify green color for fallback const int kDefaultSeedColor = 0xFF1DB954; @@ -13,12 +22,14 @@ class ThemeSettings { final bool useDynamicColor; final int seedColorValue; final bool useAmoled; + final AppThemeStyle style; const ThemeSettings({ this.themeMode = ThemeMode.system, this.useDynamicColor = true, this.seedColorValue = kDefaultSeedColor, this.useAmoled = false, + this.style = AppThemeStyle.material, }); Color get seedColor => Color(seedColorValue); @@ -28,12 +39,14 @@ class ThemeSettings { bool? useDynamicColor, int? seedColorValue, bool? useAmoled, + AppThemeStyle? style, }) { return ThemeSettings( themeMode: themeMode ?? this.themeMode, useDynamicColor: useDynamicColor ?? this.useDynamicColor, seedColorValue: seedColorValue ?? this.seedColorValue, useAmoled: useAmoled ?? this.useAmoled, + style: style ?? this.style, ); } @@ -42,6 +55,7 @@ class ThemeSettings { kUseDynamicColorKey: useDynamicColor, kSeedColorKey: seedColorValue, kUseAmoledKey: useAmoled, + kThemeStyleKey: style.name, }; factory ThemeSettings.fromJson(Map json) { @@ -50,6 +64,7 @@ class ThemeSettings { useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true, seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor, useAmoled: json[kUseAmoledKey] as bool? ?? false, + style: themeStyleFromString(json[kThemeStyleKey] as String?), ); } @@ -60,7 +75,8 @@ class ThemeSettings { other.themeMode == themeMode && other.useDynamicColor == useDynamicColor && other.seedColorValue == seedColorValue && - other.useAmoled == useAmoled; + other.useAmoled == useAmoled && + other.style == style; } @override @@ -68,7 +84,8 @@ class ThemeSettings { themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ - useAmoled.hashCode; + useAmoled.hashCode ^ + style.hashCode; } ThemeMode themeModeFromString(String? value) { diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 0d6ad0a5..64f263ea 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -194,12 +194,18 @@ class ExtensionNotifier extends Notifier { lyricsFetchOptions: snapshot.lyricsFetchOptions, allowedDirectories: allowedDirectories, ); - await loadExtensions(extensionsDir); + final loaded = await loadExtensions(extensionsDir); final loadError = state.error; - if (loadError != null) throw StateError(loadError); + if (!loaded) { + throw StateError(loadError ?? 'Failed to load installed extensions'); + } await loadProviderPriority(); await loadMetadataProviderPriority(); - state = state.copyWith(isInitialized: true, isLoading: false); + state = state.copyWith( + isInitialized: true, + isLoading: false, + error: loadError, + ); await _syncDownloadDirectory(ref.read(settingsProvider)); _log.i('Extension system initialized'); } catch (e) { @@ -234,17 +240,32 @@ class ExtensionNotifier extends Notifier { } } - Future loadExtensions(String dirPath) async { + Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); try { final result = await PlatformBridge.loadExtensionsFromDir(dirPath); _log.d('Load extensions result: $result'); + final loadErrors = (result['errors'] as List? ?? const []) + .whereType() + .where((error) => error.trim().isNotEmpty) + .toList(); await refreshExtensions(); - state = state.copyWith(isLoading: false, error: state.error); + final refreshError = state.error; + state = state.copyWith( + isLoading: false, + error: + refreshError ?? (loadErrors.isEmpty ? null : loadErrors.join('\n')), + ); + // Native loading can succeed overall while individual packages fail. + // Keep working extensions usable, but do not treat an unreadable install + // directory as an empty, successfully initialized extension system. + return refreshError == null && + (loadErrors.isEmpty || state.extensions.isNotEmpty); } catch (e) { _log.e('Failed to load extensions: $e'); state = state.copyWith(isLoading: false, error: e.toString()); + return false; } } diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 98d6f13e..b0f58be7 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -1,5 +1,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; @@ -8,6 +10,49 @@ final currentMediaItemProvider = StreamProvider((ref) { return musicPlayerMediaItemEvents(); }); +/// Use the Library's identity for favorites, including restored queues and +/// playback started from a file path rather than a Library row ID. +final playerCollectionTrackProvider = FutureProvider.autoDispose + .family((ref, item) async { + final source = item.extras?['source']?.toString() ?? ''; + if (source.isNotEmpty) { + final history = await ref + .read(downloadHistoryProvider.notifier) + .getByFilePathAsync(source); + if (history != null) { + return UnifiedLibraryItem.fromDownloadHistory(history).toTrack(); + } + } + final database = LibraryDatabase.instance; + final row = await database.getById(item.id); + if (row != null) { + return UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem.fromJson(row), + ).toTrack(); + } + if (source.isNotEmpty) { + final matches = await database.findByTrackAndArtist( + item.title, + item.artist ?? '', + ); + for (final row in matches) { + final local = LocalLibraryItem.fromJson(row); + if (local.filePath == source) { + return UnifiedLibraryItem.fromLocalLibrary(local).toTrack(); + } + } + } + return Track( + id: item.id, + name: item.title, + artistName: item.artist ?? '', + albumName: item.album ?? '', + coverUrl: item.artUri?.toString(), + duration: item.duration?.inSeconds ?? 0, + source: 'local', + ); + }); + final playbackStateProvider = StreamProvider((ref) { return musicPlayerPlaybackStateEvents(); }); diff --git a/lib/providers/player_motion_artwork_provider.dart b/lib/providers/player_motion_artwork_provider.dart new file mode 100644 index 00000000..5e6338e9 --- /dev/null +++ b/lib/providers/player_motion_artwork_provider.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/ttl_cache.dart'; + +typedef PlayerArtworkAlbum = ({String album, String artist}); + +final _motionCache = TtlCache>( + const Duration(minutes: 10), + maxEntries: 32, +); + +/// Resolve the same album motion artwork used by collection headers. Cache by +/// album so advancing through its tracks does not repeat metadata requests. +final playerMotionArtworkProvider = FutureProvider.autoDispose + .family((ref, album) { + if (album.album.trim().isEmpty || album.artist.trim().isEmpty) { + return null; + } + final extensions = ref.watch(extensionProvider); + final preferred = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + final enabled = extensions.extensions + .where((extension) => extension.enabled && extension.hasCustomSearch) + .map((extension) => extension.id) + .toSet(); + final providers = { + if (preferred != null && enabled.contains(preferred)) preferred, + ...extensions.metadataProviderPriority.where(enabled.contains), + ...enabled, + }.toList(); + if (providers.isEmpty) return null; + final key = jsonEncode([album.album, album.artist, providers]); + final cached = _motionCache.get(key); + if (cached != null) return cached; + final request = findPlayerMotionArtwork( + album: album, + providerIds: providers, + search: (provider, query) => PlatformBridge.customSearchWithExtension( + provider, + query, + options: {'filter': 'album', 'limit': 8}, + ).timeout(const Duration(seconds: 6)), + loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata( + provider, + 'album', + id, + ).timeout(const Duration(seconds: 6)), + ); + _motionCache.set(key, request); + return request; + }); + +Future findPlayerMotionArtwork({ + required PlayerArtworkAlbum album, + required List providerIds, + required Future>> Function( + String provider, + String query, + ) + search, + required Future> Function(String provider, String id) + loadAlbum, +}) async { + String normalized(String value) => + value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + for (final provider in providerIds) { + try { + final results = await search(provider, '${album.album} ${album.artist}'); + for (final result in results) { + final name = (result['name'] ?? result['album_name'] ?? '').toString(); + final artist = + (result['artists'] ?? + result['artist'] ?? + result['artist_name'] ?? + '') + .toString(); + // Never attach a similarly named album's video to the playing track. + if (normalized(name) != normalized(album.album) || + normalized(artist) != normalized(album.artist)) { + continue; + } + final direct = normalizeRemoteHttpUrl( + result['header_video']?.toString(), + ); + if (direct != null) return direct; + final id = (result['id'] ?? result['album_id'] ?? '').toString(); + if (id.isEmpty) continue; + final metadata = await loadAlbum(provider, id); + final info = metadata['album_info']; + final video = normalizeRemoteHttpUrl( + ((info is Map ? info['header_video'] : null) ?? + metadata['header_video']) + ?.toString(), + ); + if (video != null) return video; + } + } catch (_) { + // Offline, unsupported providers and expired links keep the static cover. + } + } + return null; +} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index eeb36ec0..15331079 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -13,6 +13,7 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { useDynamicColor: prefs.getBool(kUseDynamicColorKey) ?? true, seedColorValue: prefs.getInt(kSeedColorKey) ?? kDefaultSeedColor, useAmoled: prefs.getBool(kUseAmoledKey) ?? false, + style: themeStyleFromString(prefs.getString(kThemeStyleKey)), ); } @@ -42,6 +43,7 @@ class ThemeNotifier extends Notifier { useDynamicColor: useDynamic ?? true, seedColorValue: seedColor ?? kDefaultSeedColor, useAmoled: useAmoled ?? false, + style: themeStyleFromString(prefs.getString(kThemeStyleKey)), ); } catch (e) { debugPrint('Error loading theme settings: $e'); @@ -55,6 +57,7 @@ class ThemeNotifier extends Notifier { await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); await prefs.setInt(kSeedColorKey, state.seedColorValue); await prefs.setBool(kUseAmoledKey, state.useAmoled); + await prefs.setString(kThemeStyleKey, state.style.name); } catch (e) { debugPrint('Error saving theme settings: $e'); } @@ -65,6 +68,11 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } + Future setStyle(AppThemeStyle style) async { + state = state.copyWith(style: style); + await _saveToStorage(); + } + Future setUseDynamicColor(bool value) async { state = state.copyWith(useDynamicColor: value); await _saveToStorage(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 457a8ac0..d875dde5 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -562,7 +562,7 @@ class TrackNotifier extends Notifier { id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', - totalTracks: data['total_tracks'] as int? ?? 0, + totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0, coverUrl: normalizeCoverReference( (data['cover_url'] ?? data['images'])?.toString(), ), diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f94b853b..98145a9f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -18,7 +20,6 @@ import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/utils/ttl_cache.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -32,6 +33,7 @@ import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class _AlbumCache { static final _cache = TtlCache>( @@ -274,6 +276,10 @@ class _AlbumScreenState extends ConsumerState Widget _buildHeaderMeta(BuildContext context, String? releaseDate) { final items = []; + final genre = _tracks?.firstOrNull?.genre?.trim(); + if (context.isMornye && genre != null && genre.isNotEmpty) { + items.add(HeaderMetaItem(genre)); + } final year = _releaseYear(releaseDate); if (year != null) items.add(HeaderMetaItem(year)); items.addAll(_audioTraitInline()); @@ -340,11 +346,24 @@ class _AlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: + widget.coverUrl ?? _headerImageUrl ?? widget.headerImageUrl, + neutralActions: true, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final tracks = _tracks ?? []; final pageBackgroundColor = colorScheme.surface; - final bottomInset = context.navBarBottomInset; - final bottomPadding = MediaQuery.paddingOf(context).bottom; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; pruneSelection({ for (var index = 0; index < tracks.length; index++) @@ -356,7 +375,6 @@ class _AlbumScreenState extends ConsumerState isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, backgroundColor: pageBackgroundColor, - bottomInset: bottomInset, selectionBar: _buildSelectionBottomBar( context, colorScheme, @@ -366,10 +384,12 @@ class _AlbumScreenState extends ConsumerState appBar: _buildAppBar(context, colorScheme, pageBackgroundColor), slivers: [ if (_isLoading) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(16), - child: AlbumTrackListSkeleton(itemCount: 10), + padding: context.isMornye + ? EdgeInsets.zero + : const EdgeInsets.all(16), + child: const AlbumTrackListSkeleton(itemCount: 10), ), ), if (_error != null) @@ -410,7 +430,7 @@ class _AlbumScreenState extends ConsumerState motionUrl.trim().isNotEmpty && Uri.tryParse(motionUrl)?.hasAuthority == true; final coverThumbUrl = widget.coverUrl ?? _headerImageUrl; - final showSquareCover = !hasMotion; + final showSquareCover = context.isMornye || !hasMotion; final expandedHeight = calculateExpandedHeight(context); final cacheWidth = coverCacheWidthForViewport(context); final headerBgUrl = @@ -439,6 +459,8 @@ class _AlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, + immersive: context.isMornye, + squareArtwork: !hasMotion && headerBgUrl == coverThumbUrl, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, backgroundColor: pageBackgroundColor, @@ -446,7 +468,9 @@ class _AlbumScreenState extends ConsumerState paletteSource: coverThumbUrl, blurAndScrimBackground: showSquareCover, coverBuilder: showSquareCover - ? (context, coverSize) => coverThumbUrl != null + ? (context, coverSize) => context.isMornye && hasMotion + ? background + : coverThumbUrl != null ? DownloadableCover( coverUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl, baseName: [ @@ -490,8 +514,10 @@ class _AlbumScreenState extends ConsumerState extensionId: widget.extensionId, style: TextStyle( color: colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, + fontSize: context.isMornye ? 20 : 16, + fontWeight: context.isMornye + ? FontWeight.w400 + : FontWeight.w600, ), textAlign: TextAlign.center, maxLines: 1, @@ -501,6 +527,29 @@ class _AlbumScreenState extends ConsumerState meta: _buildHeaderMeta(context, releaseDate), actions: isSelectionMode ? null + : context.isMornye + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 16), + Flexible( + child: SizedBox( + width: 172, + child: HeaderFilledButton( + icon: CupertinoIcons.arrow_down_circle_fill, + label: context.l10n.dialogDownload, + tonal: true, + onPressed: tracks.isEmpty + ? null + : () => _downloadAll(context), + ), + ), + ), + const SizedBox(width: 16), + _buildAddToPlaylistButton(context), + ], + ) : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -580,6 +629,11 @@ class _AlbumScreenState extends ConsumerState forceQualityPicker: forceQualityPicker, ), clickableArtist: true, + showQualityBadges: + !context.isMornye || + !_audioTraits.any( + (trait) => trait.toLowerCase().contains('lossless'), + ), isSelectionMode: isSelectionMode, isSelected: selectedIds.contains(selectionId), onToggleSelection: () => toggleSelection(selectionId), @@ -708,6 +762,7 @@ class _AlbumScreenState extends ConsumerState return HeaderCircleButton( icon: allLoved ? Icons.favorite : Icons.favorite_border, + tonal: true, iconColor: allLoved ? Theme.of(context).colorScheme.error : null, tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved @@ -721,6 +776,7 @@ class _AlbumScreenState extends ConsumerState Widget _buildAddToPlaylistButton(BuildContext context) { return HeaderCircleButton( icon: Icons.add, + tonal: true, tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks == null || _tracks!.isEmpty ? null diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index a821270d..a385e252 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,4 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; @@ -480,6 +486,17 @@ class _ArtistScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: + _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final albums = _albums ?? []; _ensureAlbumBuckets(albums); @@ -487,7 +504,6 @@ class _ArtistScreenState extends ConsumerState final albumsOnly = _albumsOnlyBucket; final singles = _singlesBucket; final compilations = _compilationsBucket; - final bottomInset = context.navBarBottomInset; final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty; @@ -513,7 +529,7 @@ class _ArtistScreenState extends ConsumerState CustomScrollView( controller: _scrollController, slivers: [ - _buildHeader( + ..._buildHeader( context, colorScheme, albums: albums, @@ -523,10 +539,11 @@ class _ArtistScreenState extends ConsumerState SliverToBoxAdapter( child: ArtistScreenSkeleton( showCoverHeader: + !context.isMornye && (_headerImageUrl ?? - widget.headerImageUrl ?? - widget.coverUrl) == - null, + widget.headerImageUrl ?? + widget.coverUrl) == + null, showPopularSection: !widget.artistId.startsWith('deezer:') && !widget.artistId.startsWith('qobuz:') && @@ -545,13 +562,18 @@ class _ArtistScreenState extends ConsumerState ), ), if (!_isLoadingDiscography && _error == null) ...[ + if (context.isMornye && albums.isNotEmpty && !isSelectionMode) + SliverToBoxAdapter( + child: _buildMornyeFeaturedAlbum(context, albums.first), + ), if (_topTracks != null && _topTracks!.isNotEmpty) SliverToBoxAdapter( - child: _buildPopularSection(colorScheme), + child: _buildPopularSection(context, colorScheme), ), if (releases.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistReleases, releases, colorScheme, @@ -560,6 +582,7 @@ class _ArtistScreenState extends ConsumerState if (albumsOnly.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistAlbums, albumsOnly, colorScheme, @@ -568,6 +591,7 @@ class _ArtistScreenState extends ConsumerState if (singles.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistSingles, singles, colorScheme, @@ -577,6 +601,7 @@ class _ArtistScreenState extends ConsumerState if (compilations.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistCompilations, compilations, colorScheme, @@ -586,7 +611,7 @@ class _ArtistScreenState extends ConsumerState SliverToBoxAdapter( child: SizedBox(height: isSelectionMode ? 120 : 32), ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ), ], @@ -695,87 +720,108 @@ class _ArtistScreenState extends ConsumerState (sum, a) => sum + a.totalTracks, ); final singleTracks = singles.fold(0, (sum, a) => sum + a.totalTracks); + final pageContext = context; + + Widget buildSheet(BuildContext context) => AppBottomSheet( + maxHeightFactor: 0.85, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Row( + children: [ + if (!context.isMornye) ...[ + Icon(Icons.download, color: colorScheme.primary), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + context.l10n.discographyDownload, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: context.isMornye + ? FontWeight.w600 + : FontWeight.bold, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + if (albums.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.library_music, + title: context.l10n.discographyDownloadAll, + subtitle: context.l10n.discographyDownloadAllSubtitle( + totalTracks, + albums.length, + ), + onTap: () { + Navigator.pop(context); + _downloadAlbums(pageContext, albums); + }, + ), + if (albumsOnly.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.album, + title: context.l10n.discographyAlbumsOnly, + subtitle: context.l10n.discographyAlbumsOnlySubtitle( + albumTracks, + albumsOnly.length, + ), + onTap: () { + Navigator.pop(context); + _downloadAlbums(pageContext, albumsOnly); + }, + ), + if (singles.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.music_note, + title: context.l10n.discographySinglesOnly, + subtitle: context.l10n.discographySinglesOnlySubtitle( + singleTracks, + singles.length, + ), + onTap: () { + Navigator.pop(context); + _downloadAlbums(pageContext, singles); + }, + ), + _DiscographyOptionTile( + icon: Icons.checklist, + title: context.l10n.discographySelectAlbums, + subtitle: context.l10n.discographySelectAlbumsSubtitle, + showDivider: false, + onTap: () { + Navigator.pop(context); + enterSelectionMode(albums.first.id); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); showModalBottomSheet( context: context, useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - builder: (context) => SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Row( - children: [ - Icon(Icons.download, color: colorScheme.primary), - const SizedBox(width: 12), - Text( - context.l10n.discographyDownload, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), + isScrollControlled: true, + backgroundColor: context.isMornye + ? Colors.transparent + : colorScheme.surfaceContainerHigh, + builder: (sheetContext) => sheetContext.isMornye + ? Theme( + data: MornyeTheme.build(Brightness.dark), + child: MornyeGlassPanel( + tintOpacity: 0.78, + child: Builder(builder: buildSheet), ), - const Divider(height: 1), - if (albums.isNotEmpty) - _DiscographyOptionTile( - icon: Icons.library_music, - title: context.l10n.discographyDownloadAll, - subtitle: context.l10n.discographyDownloadAllSubtitle( - totalTracks, - albums.length, - ), - onTap: () { - Navigator.pop(context); - _downloadAlbums(context, albums); - }, - ), - if (albumsOnly.isNotEmpty) - _DiscographyOptionTile( - icon: Icons.album, - title: context.l10n.discographyAlbumsOnly, - subtitle: context.l10n.discographyAlbumsOnlySubtitle( - albumTracks, - albumsOnly.length, - ), - onTap: () { - Navigator.pop(context); - _downloadAlbums(context, albumsOnly); - }, - ), - if (singles.isNotEmpty) - _DiscographyOptionTile( - icon: Icons.music_note, - title: context.l10n.discographySinglesOnly, - subtitle: context.l10n.discographySinglesOnlySubtitle( - singleTracks, - singles.length, - ), - onTap: () { - Navigator.pop(context); - _downloadAlbums(context, singles); - }, - ), - _DiscographyOptionTile( - icon: Icons.checklist, - title: context.l10n.discographySelectAlbums, - subtitle: context.l10n.discographySelectAlbumsSubtitle, - onTap: () { - Navigator.pop(context); - enterSelectionMode(albums.first.id); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ), + ) + : buildSheet(sheetContext), ); } @@ -846,7 +892,7 @@ class _ArtistScreenState extends ConsumerState } final progressDialogKey = GlobalKey<_FetchingProgressDialogState>(); - showDialog( + showAppDialog( context: context, barrierDismissible: false, builder: (ctx) => _FetchingProgressDialog( diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index ab5d4914..fd4cd190 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -2,7 +2,7 @@ part of 'artist_screen.dart'; extension _ArtistScreenSections on _ArtistScreenState { - Widget _buildHeader( + List _buildHeader( BuildContext context, ColorScheme colorScheme, { required List albums, @@ -50,22 +50,92 @@ extension _ArtistScreenSections on _ArtistScreenState { ), ); - return CoverPaletteBuilder( - imageSource: hasValidImage ? imageUrl : null, - builder: (context, headerScheme) => _buildArtistAppBar( - context, - colorScheme, - headerScheme, - albums: albums, - hasDiscography: hasDiscography, - imageUrl: imageUrl, - hasValidImage: hasValidImage, - headerVideoUrl: headerVideoUrl, - hasMotionBanner: hasMotionBanner, - listenersText: listenersText, - isFavoriteArtist: isFavoriteArtist, + if (context.isMornye) { + final artwork = hasValidImage + ? DownloadableCover( + coverUrl: imageUrl, + baseName: widget.artistName, + child: CachedCoverImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + errorWidget: (_, _, _) => + const Center(child: Icon(CupertinoIcons.person, size: 80)), + ), + ) + : const Center(child: Icon(CupertinoIcons.person, size: 80)); + return MornyeArtistHeader( + name: widget.artistName, + listeners: listenersText, + showTitle: _showTitleInAppBar, + artwork: hasMotionBanner + ? MotionHeaderBanner(videoUrl: headerVideoUrl, fallback: artwork) + : artwork, + actions: isSelectionMode + ? const [] + : [ + HeaderCircleButton( + icon: isFavoriteArtist + ? CupertinoIcons.heart_fill + : CupertinoIcons.heart, + iconColor: isFavoriteArtist + ? colorScheme.primary + : Colors.white, + tooltip: isFavoriteArtist + ? context.l10n.artistOptionRemoveFromFavorites + : context.l10n.artistOptionAddToFavorites, + onPressed: () => _toggleFavoriteArtist(context), + ), + if (_isLoadingDiscography) + const ShimmerLoading( + child: SkeletonBox(width: 60, height: 60, borderRadius: 30), + ) + else if (hasDiscography) + CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(30), + color: Colors.white, + onPressed: () => + _showDiscographyOptions(context, colorScheme, albums), + child: SizedBox.square( + dimension: 60, + child: Semantics( + label: context.l10n.discographyDownload, + child: const Icon( + CupertinoIcons.arrow_down, + size: 26, + color: Colors.black, + ), + ), + ), + ), + HeaderCircleButton( + icon: CupertinoIcons.ellipsis, + tooltip: context.l10n.openInOtherServices, + onPressed: () => _showShareSheet(context), + ), + ], + ).buildSlivers(context); + } + return [ + CoverPaletteBuilder( + imageSource: hasValidImage ? imageUrl : null, + builder: (context, headerScheme) => _buildArtistAppBar( + context, + colorScheme, + headerScheme, + albums: albums, + hasDiscography: hasDiscography, + imageUrl: imageUrl, + hasValidImage: hasValidImage, + headerVideoUrl: headerVideoUrl, + hasMotionBanner: hasMotionBanner, + listenersText: listenersText, + isFavoriteArtist: isFavoriteArtist, + ), ), - ); + ]; } Widget _buildArtistAppBar( @@ -305,7 +375,82 @@ extension _ArtistScreenSections on _ArtistScreenState { ); } - Widget _buildPopularSection(ColorScheme colorScheme) { + Widget _buildMornyeFeaturedAlbum(BuildContext context, ArtistAlbum album) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), + child: Material( + color: Colors.white.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _navigateToAlbum(album), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: SizedBox.square( + dimension: 76, + child: album.coverUrl != null + ? CachedCoverImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + ) + : const Icon(CupertinoIcons.square_stack, size: 32), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (album.releaseDate.isNotEmpty) + Text( + album.releaseDate.split('-').first, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 4), + Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (album.totalTracks > 0) ...[ + const SizedBox(height: 4), + Text( + context.l10n.tracksCount(album.totalTracks), + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + CupertinoIcons.chevron_forward, + size: 14, + color: Colors.white.withValues(alpha: 0.7), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPopularSection(BuildContext context, ColorScheme colorScheme) { if (_topTracks == null || _topTracks!.isEmpty) { return const SizedBox.shrink(); } @@ -425,7 +570,7 @@ extension _ArtistScreenSections on _ArtistScreenState { final isQueued = queueItem != null; - return InkWell( + final row = InkWell( onTap: () => _handlePopularTrackTap( track, isQueued: isQueued, @@ -439,20 +584,22 @@ extension _ArtistScreenSections on _ArtistScreenState { hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 10, 8), + padding: EdgeInsets.fromLTRB(context.isMornye ? 20 : 16, 8, 10, 8), child: Row( children: [ - SizedBox( - width: 24, - child: Text( - '$rank', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + if (!context.isMornye) ...[ + SizedBox( + width: 24, + child: Text( + '$rank', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), + ], track.coverUrl != null ? DownloadableCover( coverUrl: track.coverUrl, @@ -499,7 +646,10 @@ extension _ArtistScreenSections on _ArtistScreenState { Text( track.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + fontSize: context.isMornye ? 17 : null, + fontWeight: context.isMornye + ? FontWeight.w400 + : FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -520,6 +670,7 @@ extension _ArtistScreenSections on _ArtistScreenState { style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, + fontSize: context.isMornye ? 15 : null, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -543,6 +694,22 @@ extension _ArtistScreenSections on _ArtistScreenState { ), ), ); + if (!context.isMornye) return row; + return Stack( + children: [ + row, + Positioned( + left: 80, + right: 20, + bottom: 0, + child: Divider( + height: 1, + thickness: 0.5, + color: Colors.white.withValues(alpha: 0.12), + ), + ), + ], + ); }, ); } @@ -607,6 +774,7 @@ extension _ArtistScreenSections on _ArtistScreenState { } Widget _buildAlbumSection( + BuildContext context, String title, List albums, ColorScheme colorScheme, { @@ -638,6 +806,7 @@ extension _ArtistScreenSections on _ArtistScreenState { return KeyedSubtree( key: ValueKey(album.id), child: _buildAlbumCard( + context, album, colorScheme, tileSize: tileSize, @@ -653,6 +822,7 @@ extension _ArtistScreenSections on _ArtistScreenState { } Widget _buildAlbumCard( + BuildContext context, ArtistAlbum album, ColorScheme colorScheme, { required double tileSize, @@ -866,17 +1036,62 @@ class _DiscographyOptionTile extends StatelessWidget { final String title; final String subtitle; final VoidCallback onTap; + final bool showDivider; const _DiscographyOptionTile({ required this.icon, required this.title, required this.subtitle, required this.onTap, + this.showDivider = true, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: 16), + onPressed: onTap, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.bodyLarge), + const SizedBox(height: 4), + Text( + subtitle, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues( + alpha: 0.72, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Icon( + mornyeIconFor(icon), + size: 22, + color: colorScheme.onSurface, + ), + ], + ), + ), + if (showDivider) const Divider(height: 0.5, thickness: 0.5), + ], + ), + ); + } return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( @@ -937,7 +1152,7 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { final colorScheme = Theme.of(context).colorScheme; final progress = _total > 0 ? _current / _total : 0.0; - return AlertDialog( + return AppAlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), content: Column( @@ -985,7 +1200,7 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { ], ), actions: [ - TextButton( + AppDialogAction( onPressed: widget.onCancel, child: Text(context.l10n.dialogCancel), ), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 9ed61c07..52cd60e0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -16,7 +17,6 @@ import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -34,6 +34,7 @@ import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -250,13 +251,6 @@ class _DownloadedAlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final qualityLabelMode = ref.watch( - settingsProvider.select((s) => s.libraryQualityLabelMode), - ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; - final tracksValue = ref.watch( downloadedAlbumTracksProvider( DownloadedAlbumTracksRequest( @@ -286,6 +280,25 @@ class _DownloadedAlbumScreenState extends ConsumerState pruneSelection(tracks.map((t) => t.id).toSet()); + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: _resolveAlbumEmbeddedCoverPath(tracks) ?? widget.coverUrl, + neutralActions: true, + child: Builder(builder: (context) => _buildPage(context, tracks)), + ); + } + return _buildPage(context, tracks); + } + + Widget _buildPage(BuildContext context, List tracks) { + final colorScheme = Theme.of(context).colorScheme; + final qualityLabelMode = ref.watch( + settingsProvider.select((s) => s.libraryQualityLabelMode), + ); + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; + return CollectionScaffold( scrollController: scrollController, isSelectionMode: isSelectionMode, @@ -298,7 +311,6 @@ class _DownloadedAlbumScreenState extends ConsumerState tracks, bottomPadding, ), - bottomInset: bottomInset, ); } @@ -363,6 +375,7 @@ class _DownloadedAlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, + immersive: context.isMornye, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, background: background, @@ -379,9 +392,11 @@ class _DownloadedAlbumScreenState extends ConsumerState subtitle: Text( widget.artistName, style: TextStyle( - color: HeaderPalette.of(context).onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w600, + color: context.isMornye + ? colorScheme.primary + : HeaderPalette.of(context).onSurfaceVariant, + fontSize: context.isMornye ? 20 : 16, + fontWeight: context.isMornye ? FontWeight.w400 : FontWeight.w600, ), textAlign: TextAlign.center, maxLines: 1, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 8b38b91d..9633c0d5 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -4,7 +4,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,6 +33,7 @@ import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/cover_download_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; @@ -42,7 +50,7 @@ import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; -import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; @@ -64,6 +72,7 @@ class _HomeTabState extends ConsumerState final _historySnapshot = TrackHistorySnapshot(); final _urlController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); + final _homeScrollController = ScrollController(); String? _lastSearchQuery; String? _activeSearchInput; bool _isResettingSearchSurface = false; @@ -157,6 +166,9 @@ class _HomeTabState extends ConsumerState super.initState(); _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + ShellNavigationService.homeSearchRequests.addListener( + _focusSearchFromShell, + ); // Run an initial fetch check in case extensions were already initialized // before HomeTab was mounted (e.g. auto-installed during first setup). @@ -227,6 +239,10 @@ class _HomeTabState extends ConsumerState @override void dispose() { + ShellNavigationService.homeSearchRequests.removeListener( + _focusSearchFromShell, + ); + _homeScrollController.dispose(); _liveSearchDebounce?.cancel(); _trackStateSub.close(); _extensionInitSub.close(); @@ -336,6 +352,14 @@ class _HomeTabState extends ConsumerState } } + void _focusSearchFromShell() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_homeScrollController.hasClients) _homeScrollController.jumpTo(0); + _searchFocusNode.requestFocus(); + }); + } + void _onTrackStateChanged(TrackState? previous, TrackState next) { if (previous != null && !next.hasContent && @@ -743,7 +767,6 @@ class _HomeTabState extends ConsumerState trackProvider.select((s) => s.isShowingRecentAccess), ); final screenHeight = MediaQuery.sizeOf(context).height; - final bottomInset = context.navBarBottomInset; final hasHistoryItems = ref.watch( _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), ); @@ -833,6 +856,7 @@ class _HomeTabState extends ConsumerState onRefresh: () => ref.read(exploreProvider.notifier).refresh(), notificationPredicate: (notification) => showExplore, child: CustomScrollView( + controller: _homeScrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ AppSliverHeader.tabRoot(title: context.l10n.homeTitle), @@ -860,7 +884,7 @@ class _HomeTabState extends ConsumerState 16, (hasResults || showExplore) ? 8 : 16, ), - child: _buildSearchBar(colorScheme), + child: _buildSearchBar(), ), ), @@ -1044,7 +1068,7 @@ class _HomeTabState extends ConsumerState ); }, ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ), ), @@ -1096,6 +1120,11 @@ class _HomeTabState extends ConsumerState ); } + // The Mornye style uses the navigation title as the page identity. Keep + // the search surface directly beneath it instead of repeating app branding + // in the content area. + if (context.isMornye) return const SizedBox.shrink(); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart index b25850e1..845af69f 100644 --- a/lib/screens/home_tab_import.dart +++ b/lib/screens/home_tab_import.dart @@ -17,7 +17,7 @@ extension _HomeTabCsvImport on _HomeTabState { if (progressDialogInitialized || !mounted) return; progressDialogInitialized = true; progressDialogVisible = true; - showDialog( + showAppDialog( context: this.context, useRootNavigator: false, barrierDismissible: false, @@ -25,7 +25,7 @@ extension _HomeTabCsvImport on _HomeTabState { builder: (dialogCtx, setState) { progressDialogContext = dialogCtx; setDialogState = setState; - return AlertDialog( + return AppAlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -88,13 +88,13 @@ extension _HomeTabCsvImport on _HomeTabState { final l10n = this.context.l10n; - final options = await showDialog<_CsvImportOptions>( + final options = await showAppDialog<_CsvImportOptions>( context: this.context, useRootNavigator: false, builder: (dialogCtx) { var skipDownloaded = true; return StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( + builder: (dialogCtx, setDialogState) => AppAlertDialog( title: Text(l10n.dialogImportPlaylistTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -102,20 +102,30 @@ extension _HomeTabCsvImport on _HomeTabState { children: [ Text(l10n.dialogImportPlaylistMessage(tracks.length)), const SizedBox(height: 12), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.homeSkipAlreadyDownloaded), - value: skipDownloaded, - onChanged: (value) { - setDialogState(() { - skipDownloaded = value ?? true; - }); - }, - ), + if (dialogCtx.isMornye) + AppSwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.homeSkipAlreadyDownloaded), + value: skipDownloaded, + onChanged: (value) => setDialogState(() { + skipDownloaded = value; + }), + ) + else + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.homeSkipAlreadyDownloaded), + value: skipDownloaded, + onChanged: (value) { + setDialogState(() { + skipDownloaded = value ?? true; + }); + }, + ), ], ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop( dialogCtx, const _CsvImportOptions( @@ -125,7 +135,8 @@ extension _HomeTabCsvImport on _HomeTabState { ), child: Text(l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop( dialogCtx, _CsvImportOptions( diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index 29d2d040..fb985976 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -25,18 +25,20 @@ extension _HomeTabRecentUI on _HomeTabState { TextButton( onPressed: () async { // Clearing history is not undoable, so ask first. - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (dialogContext) => AlertDialog( + builder: (dialogContext) => AppAlertDialog( title: Text(dialogContext.l10n.dialogClearAll), content: Text(dialogContext.l10n.recentClearAllMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(dialogContext.l10n.dialogClearAll), diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 7302d145..c0a95899 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -177,14 +177,18 @@ extension _HomeTabSearchResultsUI on _HomeTabState { void _showSortOptions(ColorScheme colorScheme) { var tempSort = _searchSortOption; + final mornye = context.isMornye; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, - backgroundColor: colorScheme.surfaceContainerLow, + backgroundColor: mornye + ? Colors.transparent + : colorScheme.surfaceContainerLow, + elevation: mornye ? 0 : null, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheetState) { - return SafeArea( + final content = SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( @@ -214,6 +218,14 @@ extension _HomeTabSearchResultsUI on _HomeTabState { spacing: 8, runSpacing: 8, children: HomeSearchSortOption.values.map((option) { + if (context.isMornye) { + return MornyeFilterChip( + label: _sortOptionLabel(option), + selected: tempSort == option, + glass: false, + onTap: () => setSheetState(() => tempSort = option), + ); + } return FilterChip( label: Text(_sortOptionLabel(option)), selected: tempSort == option, @@ -242,6 +254,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { ), ), ); + return mornye ? MornyeGlassPanel.overlay(child: content) : content; }, ), ); @@ -452,22 +465,31 @@ extension _HomeTabSearchResultsUI on _HomeTabState { colorScheme.surface, ) : colorScheme.surfaceContainerHighest; + final mornye = context.isMornye; return [ SliverToBoxAdapter( child: Padding( // Aligned with the clamped result list below on wide screens. padding: - EdgeInsets.fromLTRB(16, 8, 8, 8) + + (mornye + ? const EdgeInsets.fromLTRB(20, 16, 12, 10) + : const EdgeInsets.fromLTRB(16, 8, 8, 8)) + EdgeInsets.symmetric(horizontal: wideListInset(context)), child: Row( children: [ Expanded( child: Text( title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: mornye + ? Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ) + : Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), if (showSortButton) @@ -476,7 +498,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { child: TextButton.icon( onPressed: () => _showSortOptions(colorScheme), icon: Icon( - Icons.swap_vert, + mornye ? mornyeIconFor(Icons.swap_vert) : Icons.swap_vert, size: 18, color: _searchSortOption != HomeSearchSortOption.defaultOrder @@ -511,6 +533,15 @@ extension _HomeTabSearchResultsUI on _HomeTabState { delegate: SliverChildBuilderDelegate((context, index) { final isFirst = index == 0; final isLast = index == itemCount - 1; + if (mornye) { + return Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, isLast ? 0 : 8), + child: MornyeGlassPanel.overlay( + radius: 24, + child: itemBuilder(index, false), + ), + ); + } return StaggeredListItem( index: index, child: Container( @@ -675,6 +706,33 @@ extension _HomeTabSearchResultsUI on _HomeTabState { String? selectedFilter, ColorScheme colorScheme, ) { + Widget filterChip(String id, String label, {IconData? icon}) { + void select() { + ref.read(trackProvider.notifier).setSearchFilter(id); + _triggerSearchWithFilter(id); + } + + return context.isMornye + ? MornyeGlassPanel.overlay( + radius: 24, + child: MornyeFilterChip( + label: label, + selected: selectedFilter == id, + icon: icon == null ? null : mornyeIconFor(icon), + glass: false, + tonal: selectedFilter == id, + onTap: select, + ), + ) + : FilterChip( + label: Text(label), + selected: selectedFilter == id, + onSelected: (_) => select(), + showCheckmark: false, + avatar: icon == null ? null : Icon(icon, size: 18), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SingleChildScrollView( @@ -683,31 +741,17 @@ extension _HomeTabSearchResultsUI on _HomeTabState { children: [ Padding( padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(context.l10n.historyFilterAll), - selected: selectedFilter == 'all', - onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter('all'); - _triggerSearchWithFilter('all'); - }, - showCheckmark: false, - ), + child: filterChip('all', context.l10n.historyFilterAll), ), ...filters.map((filter) { - final isSelected = selectedFilter == filter.id; return Padding( padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(filter.label ?? filter.id), - selected: isSelected, - onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter(filter.id); - _triggerSearchWithFilter(filter.id); - }, - showCheckmark: false, - avatar: filter.icon != null - ? Icon(_getFilterIcon(filter.icon!), size: 18) - : null, + child: filterChip( + filter.id, + filter.label ?? filter.id, + icon: filter.icon == null + ? null + : _getFilterIcon(filter.icon!), ), ); }), @@ -747,75 +791,53 @@ extension _HomeTabSearchResultsUI on _HomeTabState { _performSearch(text, filterOverride: filter); } - Widget _buildSearchBar(ColorScheme colorScheme) { + Widget _buildSearchBar() { final hasText = _urlController.text.isNotEmpty; - return TextField( + return AppSearchField( controller: _urlController, focusNode: _searchFocusNode, - autofocus: false, - decoration: InputDecoration( - hintText: _getSearchHint(), - filled: true, - fillColor: settingsGroupColor(context), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - prefixIcon: _SearchProviderDropdown( - onProviderChanged: () { - _lastSearchQuery = null; - ref.read(trackProvider.notifier).setSearchFilter(null); - setState(() {}); - final text = _urlController.text.trim(); - if (text.isNotEmpty && - text.length >= _HomeTabState._minLiveSearchChars) { - _performSearch(text); - } - }, - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasText) - IconButton( - icon: const Icon(Icons.clear), - onPressed: _clearAndRefresh, - tooltip: context.l10n.dialogClear, - ) - else ...[ - IconButton( - icon: const Icon(Icons.file_upload_outlined), - onPressed: _isCsvImporting - ? null - : () => _importCsv(context, ref), - tooltip: context.l10n.homeImportCsvTooltip, - ), - IconButton( - icon: const Icon(Icons.paste), - onPressed: _pasteFromClipboard, - tooltip: context.l10n.actionPaste, - ), - ], + hintText: _getSearchHint(), + clearTooltip: context.l10n.dialogClear, + onClear: _clearAndRefresh, + prefixIcon: _SearchProviderDropdown( + onProviderChanged: () { + _lastSearchQuery = null; + ref.read(trackProvider.notifier).setSearchFilter(null); + setState(() {}); + final text = _urlController.text.trim(); + if (text.isNotEmpty && + text.length >= _HomeTabState._minLiveSearchChars) { + _performSearch(text); + } + }, + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasText) + IconButton( + icon: const Icon(Icons.clear), + onPressed: _clearAndRefresh, + tooltip: context.l10n.dialogClear, + ) + else ...[ + IconButton( + icon: const Icon(Icons.file_upload_outlined), + onPressed: _isCsvImporting + ? null + : () => _importCsv(context, ref), + tooltip: context.l10n.homeImportCsvTooltip, + ), + IconButton( + icon: const Icon(Icons.paste), + onPressed: _pasteFromClipboard, + tooltip: context.l10n.actionPaste, + ), ], - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), + ], ), onSubmitted: (_) => _onSearchSubmitted(), - onTapOutside: (_) { - FocusScope.of(context).unfocus(); - }, ); } diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index a08afdfd..f0bb1d5e 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -272,7 +272,12 @@ class _TrackItemWithStatus extends ConsumerWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), + padding: EdgeInsets.fromLTRB( + 12, + context.isMornye ? 12 : 10, + 6, + context.isMornye ? 12 : 10, + ), child: Row( children: [ Semantics( @@ -314,7 +319,9 @@ class _TrackItemWithStatus extends ConsumerWidget { Text( track.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: context.isMornye + ? FontWeight.w600 + : FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -485,7 +492,10 @@ class _CollectionItemWidget extends StatelessWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: context.isMornye ? 12 : 10, + ), child: Row( children: [ cover, @@ -497,7 +507,9 @@ class _CollectionItemWidget extends StatelessWidget { Text( item.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: context.isMornye + ? FontWeight.w600 + : FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -521,9 +533,11 @@ class _CollectionItemWidget extends StatelessWidget { ), ), Icon( - Icons.chevron_right, + context.isMornye + ? mornyeIconFor(Icons.chevron_right) + : Icons.chevron_right, color: colorScheme.onSurfaceVariant, - size: 24, + size: context.isMornye ? 18 : 24, ), ], ), @@ -686,6 +700,8 @@ class _LoadingOrErrorScaffold extends StatelessWidget { final String? error; final Widget loadingBody; final VoidCallback onRetry; + final bool collectionHeader; + final String? coverUrl; const _LoadingOrErrorScaffold({ required this.title, @@ -693,13 +709,62 @@ class _LoadingOrErrorScaffold extends StatelessWidget { required this.error, required this.loadingBody, required this.onRetry, + this.collectionHeader = false, + this.coverUrl, }); @override Widget build(BuildContext context) { if (isLoading) { + if (context.isMornye && collectionHeader) { + return MornyeArtistSurface( + imageSource: coverUrl, + neutralActions: true, + child: Builder( + builder: (context) => Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + leadingWidth: 68, + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + ), + body: MediaQuery.removePadding( + context: context, + removeTop: true, + child: loadingBody, + ), + ), + ), + ); + } return Scaffold( - appBar: AppBar(title: Text(title)), + appBar: AppBar( + title: Text(title), + leadingWidth: context.isMornye ? 68 : null, + leading: context.isMornye + ? Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ) + : null, + ), body: loadingBody, ); } @@ -780,6 +845,52 @@ class _ArtistLoadingScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: coverUrl, + child: Builder( + builder: (context) => Scaffold( + body: CustomScrollView( + slivers: [ + ...MornyeArtistHeader( + name: artistName, + showTitle: false, + artwork: coverUrl != null && coverUrl!.isNotEmpty + ? CachedCoverImage( + imageUrl: coverUrl!, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + ) + : const ShimmerLoading( + child: SkeletonBox( + width: double.infinity, + height: 342, + borderRadius: 0, + ), + ), + actions: const [ + ShimmerLoading( + child: Row( + spacing: 24, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + SkeletonBox(width: 60, height: 60, borderRadius: 30), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ), + ], + ).buildSlivers(context), + const SliverToBoxAdapter( + child: ArtistScreenSkeleton(showCoverHeader: false), + ), + ], + ), + ), + ), + ); + } final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final edgeInset = detailHeaderEdgeInset(context); @@ -1039,6 +1150,8 @@ class _ExtensionAlbumScreenState extends ConsumerState { if (_isLoading || _error != null) { return _LoadingOrErrorScaffold( title: widget.albumName, + collectionHeader: true, + coverUrl: widget.coverUrl, isLoading: _isLoading, error: _error, loadingBody: const AlbumTrackListSkeleton( @@ -1173,6 +1286,8 @@ class _ExtensionPlaylistScreenState return SkeletonCrossfade( child: _LoadingOrErrorScaffold( title: widget.playlistName, + collectionHeader: true, + coverUrl: widget.coverUrl, isLoading: _isLoading, error: _error, loadingBody: const TrackListSkeleton( @@ -1302,7 +1417,7 @@ class _ExtensionArtistScreenState extends ConsumerState name: (data['name'] ?? '').toString(), artists: (data['artists'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(), - totalTracks: data['total_tracks'] as int? ?? 0, + totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0, coverUrl: normalizeCoverReference(data['cover_url']?.toString()), albumType: (data['album_type'] ?? 'album').toString(), providerId: (data['provider_id'] ?? widget.extensionId).toString(), diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 9e628a9e..a52e433c 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -7,6 +7,9 @@ import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; @@ -24,7 +27,6 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; @@ -306,14 +308,14 @@ class _LibraryTracksFolderScreenState downloadHistoryVisibleBatchExistsProvider(_folderData.historyRequest), ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; return CollectionScaffold( scrollController: scrollController, isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, - bottomInset: bottomInset, selectionBar: _buildSelectionBottomBar( context, colorScheme, @@ -762,7 +764,7 @@ class _LibraryTracksFolderScreenState void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -772,23 +774,28 @@ class _LibraryTracksFolderScreenState children: [ const AppSheetHandle(), const SizedBox(height: 16), - ListTile( + AppSheetOption( contentPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 4, ), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_outlined, - color: colorScheme.onPrimaryContainer, - ), - ), + leading: context.isMornye + ? Icon( + mornyeIconFor(Icons.image_outlined), + color: colorScheme.primary, + ) + : Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_outlined, + color: colorScheme.onPrimaryContainer, + ), + ), title: Text(context.l10n.collectionPlaylistChangeCover), onTap: () { Navigator.pop(sheetContext); @@ -796,23 +803,28 @@ class _LibraryTracksFolderScreenState }, ), if (hasCustomCover) - ListTile( + AppSheetOption( contentPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 4, ), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.delete_outline, - color: colorScheme.onErrorContainer, - ), - ), + leading: context.isMornye + ? Icon( + mornyeIconFor(Icons.delete_outline), + color: colorScheme.primary, + ) + : Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: colorScheme.onErrorContainer, + ), + ), title: Text(context.l10n.collectionPlaylistRemoveCover), onTap: () { Navigator.pop(sheetContext); @@ -834,10 +846,10 @@ class _LibraryTracksFolderScreenState final controller = TextEditingController(text: currentName); final formKey = GlobalKey(); - final nextName = await showDialog( + final nextName = await showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.collectionRenamePlaylist), content: Form( key: formKey, @@ -861,11 +873,12 @@ class _LibraryTracksFolderScreenState ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { if (formKey.currentState?.validate() != true) return; Navigator.of(dialogContext).pop(controller.text.trim()); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 9c19a6ac..c70fad3f 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,7 +12,6 @@ import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; @@ -29,6 +29,7 @@ import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -164,12 +165,24 @@ class _LocalAlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: widget.coverPath, + neutralActions: true, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final qualityLabelMode = ref.watch( settingsProvider.select((s) => s.libraryQualityLabelMode), ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; final tracks = _sortedTracksCache; if (tracks.isEmpty) { @@ -193,7 +206,6 @@ class _LocalAlbumScreenState extends ConsumerState tracks, bottomPadding, ), - bottomInset: bottomInset, ); } @@ -225,6 +237,7 @@ class _LocalAlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, + immersive: context.isMornye, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, background: background, @@ -258,9 +271,11 @@ class _LocalAlbumScreenState extends ConsumerState subtitle: Text( widget.artistName, style: TextStyle( - color: HeaderPalette.of(context).onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w600, + color: context.isMornye + ? colorScheme.primary + : HeaderPalette.of(context).onSurfaceVariant, + fontSize: context.isMornye ? 20 : 16, + fontWeight: context.isMornye ? FontWeight.w400 : FontWeight.w600, ), textAlign: TextAlign.center, maxLines: 1, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 51ef9bb5..bf1d9088 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -30,6 +32,8 @@ import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; @@ -46,6 +50,7 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState with SingleTickerProviderStateMixin, WidgetsBindingObserver { int _currentIndex = 0; + final _mornyeChrome = MornyeChromeController(); // Preserves the PageView element (and its kept-alive tabs) when the body // structure swaps between rail and bottom-bar layouts on rotation. final GlobalKey _pageViewKey = GlobalKey(); @@ -163,7 +168,7 @@ class _MainShellState extends ConsumerState _safRepairDialogVisible = true; try { - await showDialog( + await showAppDialog( context: context, barrierDismissible: false, builder: (dialogContext) { @@ -172,7 +177,7 @@ class _MainShellState extends ConsumerState builder: (context, setDialogState) { return PopScope( canPop: false, - child: AlertDialog( + child: AppAlertDialog( icon: Icon( Icons.folder_off_outlined, color: Theme.of(context).colorScheme.error, @@ -180,7 +185,7 @@ class _MainShellState extends ConsumerState title: Text(context.l10n.downloadFolderAccessLostTitle), content: Text(context.l10n.downloadFolderAccessLostSubtitle), actions: [ - TextButton( + AppDialogAction( onPressed: isPickingFolder ? null : () { @@ -194,7 +199,8 @@ class _MainShellState extends ConsumerState }, child: Text(context.l10n.storageAutomaticFolder), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: isPickingFolder ? null : () async { @@ -393,10 +399,10 @@ class _MainShellState extends ConsumerState final colorScheme = Theme.of(context).colorScheme; - showDialog( + showAppDialog( context: context, barrierDismissible: false, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( icon: Icon( Icons.folder_special_outlined, size: 32, @@ -417,11 +423,12 @@ class _MainShellState extends ConsumerState ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx), child: Text(context.l10n.updateLater), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () async { Navigator.pop(ctx); final result = await PlatformBridge.pickSafTree(); @@ -459,6 +466,7 @@ class _MainShellState extends ConsumerState _shareSubscription?.cancel(); _pageController.dispose(); _tabJumpTransitionController.dispose(); + _mornyeChrome.dispose(); super.dispose(); } @@ -489,6 +497,7 @@ class _MainShellState extends ConsumerState } void _onNavTap(int index) { + _mornyeChrome.expand(); if (index == 0 && _currentIndex == 0) { _resetHomeToMain(); return; @@ -513,7 +522,12 @@ class _MainShellState extends ConsumerState // Jump directly when skipping intermediate tabs to avoid // sliding through them. For those jumps, keep a short fade-in // so the transition still feels intentional. - if (isNonAdjacentJump) { + if (context.isMornye) { + // The glass pill owns the tab transition. Sliding/fading the whole + // page at the same time continuously invalidates its live backdrop. + _tabJumpTransitionController.value = 1; + _pageController.jumpToPage(index); + } else if (isNonAdjacentJump) { _pageController.jumpToPage(index); _tabJumpTransitionController.forward(from: 0); } else { @@ -527,6 +541,7 @@ class _MainShellState extends ConsumerState } void _onPageChanged(int index) { + _mornyeChrome.expand(); if (_currentIndex != index) { ref.read(previewPlayerProvider.notifier).stop(); setState(() => _currentIndex = index); @@ -695,7 +710,9 @@ class _MainShellState extends ConsumerState final l10n = context.l10n; final destinations = [ NavigationDestination( - icon: const Icon(Icons.home_outlined), + icon: Icon( + context.isMornye ? CupertinoIcons.house_fill : Icons.home_outlined, + ), selectedIcon: BouncingIcon(child: const Icon(Icons.home)), label: l10n.navHome, ), @@ -705,7 +722,11 @@ class _MainShellState extends ConsumerState child: Badge( isLabelVisible: queueState > 0, label: Text('$queueState'), - child: const Icon(Icons.library_music_outlined), + child: Icon( + context.isMornye + ? CupertinoIcons.square_stack_fill + : Icons.library_music_outlined, + ), ), ), selectedIcon: SlidingIcon( @@ -727,7 +748,11 @@ class _MainShellState extends ConsumerState child: Badge( isLabelVisible: repoUpdatesCount > 0, label: Text('$repoUpdatesCount'), - child: const Icon(Icons.extension_outlined), + child: Icon( + context.isMornye + ? CupertinoIcons.square_grid_2x2 + : Icons.extension_outlined, + ), ), ), selectedIcon: BouncingIcon( @@ -743,7 +768,9 @@ class _MainShellState extends ConsumerState label: l10n.navStore, ), NavigationDestination( - icon: const Icon(Icons.settings_outlined), + icon: Icon( + context.isMornye ? CupertinoIcons.gear : Icons.settings_outlined, + ), selectedIcon: SpinIcon(child: const Icon(Icons.settings)), label: l10n.navSettings, ), @@ -765,6 +792,11 @@ class _MainShellState extends ConsumerState // desktop-width windows keeps all primary destinations at the reachable // bottom edge on iPad and large Android tablets. final useNavigationRail = useNavigationRailForWidth(screenSize.width); + final canMinimizeChrome = + context.isMornye && + !useNavigationRail && + MediaQuery.viewInsetsOf(context).bottom == 0 && + MediaQuery.textScalerOf(context).scale(15) <= 20; final pageView = KeyedSubtree( key: _pageViewKey, @@ -782,7 +814,15 @@ class _MainShellState extends ConsumerState key: ValueKey('page-$index'), child: TickerMode( enabled: index == _currentIndex, - child: tabs[index], + child: NotificationListener( + onNotification: (notification) { + if (canMinimizeChrome && index == _currentIndex) { + return _mornyeChrome.handleScroll(notification); + } + return false; + }, + child: tabs[index], + ), ), ), ), @@ -858,6 +898,45 @@ class _MainShellState extends ConsumerState : pageView, bottomNavigationBar: Builder( builder: (context) { + if (context.isMornye) { + return ListenableBuilder( + listenable: Listenable.merge([ + ShellNavigationService.chromeBrightness, + ShellNavigationService.chromeSurface, + ]), + builder: (context, child) => Theme( + data: ShellNavigationService.chromeBrightness.value == null + ? Theme.of(context) + : MornyeTheme.build( + ShellNavigationService.chromeBrightness.value!, + chromeSurface: + ShellNavigationService.chromeSurface.value, + ), + child: child!, + ), + child: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: useNavigationRail + ? const MiniPlayer() + : ValueListenableBuilder( + valueListenable: _mornyeChrome, + builder: (context, collapsed, _) => MornyeBottomBar( + collapsed: canMinimizeChrome && collapsed, + destinations: destinations, + selectedIndex: _currentIndex.clamp(0, maxIndex), + onSelected: _onNavTap, + onExpand: _mornyeChrome.expand, + onSearch: + ShellNavigationService.requestHomeSearch, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + ), + ), + ), + ); + } final bottomBar = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -931,6 +1010,9 @@ class _TabNavigator extends StatefulWidget { } class _TabNavigatorState extends State<_TabNavigator> { + late final ShellChromeObserver _chromeObserver = ShellChromeObserver( + widget.navigatorKey, + ); // Nested navigators get no HeroController from MaterialApp; without one, // Hero widgets on routes pushed inside a tab never fly. final HeroController _heroController = @@ -938,6 +1020,7 @@ class _TabNavigatorState extends State<_TabNavigator> { @override void dispose() { + _chromeObserver.detach(); _heroController.dispose(); super.dispose(); } @@ -947,6 +1030,7 @@ class _TabNavigatorState extends State<_TabNavigator> { return Navigator( key: widget.navigatorKey, observers: [ + _chromeObserver, if (widget.heroAnimationsEnabled) _heroController, ...widget.observers, ], diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f997b44f..916346e7 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1,16 +1,21 @@ import 'dart:async'; +import 'dart:ui' show BoxHeightStyle, ImageFilter; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; @@ -24,6 +29,18 @@ import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; +import 'package:spotiflac_android/widgets/mornye_player_background.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; +import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/mornye_landscape_player.dart'; +import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; +import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart'; final _log = AppLogger('NowPlaying'); @@ -37,8 +54,13 @@ const kNowPlayingArtworkHeroTag = 'now-playing-artwork'; class NowPlayingRoute extends PageRoute { NowPlayingRoute() : super(fullscreenDialog: true); - bool _dragging = false; - bool _popViaDrag = false; + bool _interactiveTransition = false; + int _dragGeneration = 0; + + // Keep the previous page painted where a drag exposes it. The player itself + // fills the screen, including the status bar and bottom safe area. + @override + bool get opaque => false; @override Color? get barrierColor => null; @@ -56,8 +78,14 @@ class NowPlayingRoute extends PageRoute { Duration get reverseTransitionDuration => const Duration(milliseconds: 300); void startDrag() { - _dragging = true; + _dragGeneration++; controller?.stop(); + if (!_interactiveTransition && controller != null) { + controller!.value = Easing.emphasizedDecelerate.transform( + controller!.value, + ); + } + _interactiveTransition = true; changedInternalState(); } @@ -66,22 +94,34 @@ class NowPlayingRoute extends PageRoute { } void endDrag(DragEndDetails details, double pageHeight) { - _dragging = false; - changedInternalState(); final velocity = (details.primaryVelocity ?? 0) / pageHeight; final value = controller?.value ?? 1.0; if (velocity > 1.0 || (velocity >= 0 && value < 0.7)) { - _popViaDrag = true; navigator?.pop(); } else { - controller?.fling(); + _settleOpen(); } } void cancelDrag() { - _dragging = false; - changedInternalState(); - controller?.fling(); + _settleOpen(); + } + + void _settleOpen() { + final generation = _dragGeneration; + // Keep the same linear position mapping until settling finishes. Switching + // to the entrance curve on release jumps away from the finger's position. + controller + ?.animateTo( + 1, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ) + .whenCompleteOrCancel(() { + if (generation != _dragGeneration || !isCurrent) return; + _interactiveTransition = false; + changedInternalState(); + }); } @override @@ -101,27 +141,17 @@ class NowPlayingRoute extends PageRoute { Widget child, ) { final reversing = animation.status == AnimationStatus.reverse; - // Button/back pop: the sheet stays put and fades out where it is, so the - // Hero cover is the only thing traveling down to the mini player. A drag - // dismissal instead keeps sliding from the finger's position, dissolving - // on the way down. - final inPlacePop = reversing && !_popViaDrag; - // Linear while dragging so the page tracks the finger 1:1. - final slide = _dragging + // Mornye's full-bleed artwork travels with its panel. Material keeps the + // independent cover flight back to the mini-player on button/back pop. + final inPlacePop = + reversing && !_interactiveTransition && !context.isMornye; + final slide = _interactiveTransition ? animation - : CurvedAnimation( - parent: animation, - curve: Easing.emphasizedDecelerate, - reverseCurve: Easing.emphasizedAccelerate, - ); + : animation.drive(CurveTween(curve: Easing.emphasizedDecelerate)); final position = inPlacePop ? const AlwaysStoppedAnimation(Offset.zero) : slide.drive(Tween(begin: const Offset(0, 1), end: Offset.zero)); - final opacity = inPlacePop - ? CurvedAnimation(parent: animation, curve: const Interval(0.3, 1.0)) - : reversing - ? CurvedAnimation(parent: animation, curve: const Interval(0.0, 0.5)) - : const AlwaysStoppedAnimation(1.0); + final opacity = inPlacePop ? animation : kAlwaysCompleteAnimation; return SlideTransition( position: position, child: FadeTransition(opacity: opacity, child: child), @@ -184,6 +214,7 @@ class _NowPlayingScreenState extends ConsumerState { ParsedLyrics _lyrics = ParsedLyrics.empty; bool _loadingMeta = false; int _currentPage = 0; + bool _landscapeLyrics = false; bool _bottomDragForwarding = false; double _bottomDragTotal = 0; bool _queueSheetShowing = false; @@ -198,7 +229,7 @@ class _NowPlayingScreenState extends ConsumerState { // When automatic playback advances while Lyrics is already visible, // onPageChanged will not run again. Inspect an unresolved SAF URI now // instead of leaving the new track with an empty Lyrics page. - inspectUnresolvedContentUri: _currentPage == 1, + inspectUnresolvedContentUri: _currentPage == 1 || _landscapeLyrics, ), ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -207,6 +238,21 @@ class _NowPlayingScreenState extends ConsumerState { }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final size = MediaQuery.sizeOf(context); + final landscapeLyrics = context.isMornye && size.width > size.height; + if (landscapeLyrics == _landscapeLyrics) return; + _landscapeLyrics = landscapeLyrics; + if (landscapeLyrics) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + } + @override void dispose() { _mediaItemSub?.close(); @@ -340,7 +386,10 @@ class _NowPlayingScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final colorScheme = mornye + ? MornyeTheme.build(Brightness.dark).colorScheme + : Theme.of(context).colorScheme; final mediaItem = ref.watch(currentMediaItemProvider).value; final controller = ref.read(musicPlayerControllerProvider); @@ -358,48 +407,81 @@ class _NowPlayingScreenState extends ConsumerState { } final source = mediaItem.extras?['source']?.toString() ?? ''; + final motionArtwork = mornye && !MediaQuery.disableAnimationsOf(context) + ? ref + .watch( + playerMotionArtworkProvider(( + album: mediaItem.album ?? '', + artist: mediaItem.artist ?? '', + )), + ) + .value + : null; - // Fade the page content in during the last 60% of the slide-up so only - // the sheet surface and the flying Hero artwork move together; without - // this the title/controls slide separately and look detached from the - // cover. On pop/drag the content fades out first, then the cover flies. + // The Mornye route moves the entire player together. A second, + // delayed content fade would make dismissal appear to pause partway down. final routeAnimation = ModalRoute.of(context)?.animation ?? kAlwaysCompleteAnimation; - final contentOpacity = CurvedAnimation( - parent: routeAnimation, - curve: const Interval(0.4, 1.0, curve: Curves.easeOut), - ); + final contentOpacity = mornye + ? kAlwaysCompleteAnimation + : routeAnimation.drive( + CurveTween(curve: const Interval(0.4, 1.0, curve: Curves.easeOut)), + ); - return Scaffold( - backgroundColor: colorScheme.surface, + final player = Scaffold( + backgroundColor: mornye ? Colors.transparent : colorScheme.surface, appBar: _FadingAppBar( opacity: contentOpacity, child: AppBar( - backgroundColor: colorScheme.surface, + backgroundColor: mornye ? Colors.transparent : colorScheme.surface, + foregroundColor: colorScheme.onSurface, surfaceTintColor: Colors.transparent, - title: Text(context.l10n.nowPlayingTitle), + systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null, + toolbarHeight: mornye ? 36 : kToolbarHeight, + automaticallyImplyLeading: false, + title: mornye + ? IconButton( + tooltip: context.l10n.nowPlayingMinimize, + onPressed: () => Navigator.of(context).maybePop(), + icon: Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(3), + ), + ), + ) + : Text(context.l10n.nowPlayingTitle), centerTitle: true, - leading: IconButton( - tooltip: context.l10n.nowPlayingMinimize, - icon: const Icon(Icons.keyboard_arrow_down), - onPressed: () => Navigator.of(context).maybePop(), - ), - actions: [ - IconButton( - tooltip: context.l10n.nowPlayingUpNext, - icon: const Icon(Icons.queue_music), - onPressed: () => _showQueueSheet(colorScheme), - ), - IconButton( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - icon: const Icon(Icons.more_vert), - onPressed: () => _showMoreActions( - mediaItem: mediaItem, - source: source, - colorScheme: colorScheme, - ), - ), - ], + leading: mornye + ? null + : IconButton( + tooltip: context.l10n.nowPlayingMinimize, + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: mornye + ? null + : [ + IconButton( + tooltip: context.l10n.nowPlayingUpNext, + icon: const Icon(Icons.queue_music), + onPressed: () => _showQueueSheet(colorScheme), + ), + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).moreButtonTooltip, + icon: const Icon(Icons.more_vert), + onPressed: () => _showMoreActions( + context: context, + mediaItem: mediaItem, + source: source, + colorScheme: colorScheme, + ), + ), + ], ), ), body: FadeTransition( @@ -408,39 +490,87 @@ class _NowPlayingScreenState extends ConsumerState { child: Column( children: [ Expanded( - child: PageView( - controller: _pageController, - onPageChanged: (page) { - if (_currentPage != page) { - setState(() => _currentPage = page); - } - if (page == 1) { - _loadMetadataForItem( - ref.read(currentMediaItemProvider).value, - inspectUnresolvedContentUri: true, - ); - } - }, - children: [ - _playerPage(mediaItem, controller, colorScheme), - _lyricsSection(colorScheme, isActive: _currentPage == 1), - ], - ), + child: mornye + ? _mornyePlayerPage( + mediaItem, + controller, + colorScheme, + squareArtwork: motionArtwork == null, + ) + : PageView( + controller: _pageController, + onPageChanged: (page) { + if (_currentPage != page) { + setState(() => _currentPage = page); + } + if (page == 1) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + }, + children: [ + _playerPage(mediaItem, controller, colorScheme), + _lyricsSection( + colorScheme, + isActive: _currentPage == 1, + ), + ], + ), ), _queueSwipeRegion( colorScheme, Column( mainAxisSize: MainAxisSize.min, children: [ - _PageTabBar( - controller: _pageController, - colorScheme: colorScheme, - labels: [ - context.l10n.nowPlayingTabPlayer, - context.l10n.nowPlayingTabLyrics, - ], - ), - const SizedBox(height: 8), + if (mornye && !_landscapeLyrics) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: _currentPage == 1 + ? context.l10n.nowPlayingTabPlayer + : context.l10n.nowPlayingTabLyrics, + isSelected: _currentPage == 1, + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: _currentPage == 1 + ? Colors.white.withValues(alpha: 0.16) + : Colors.transparent, + ), + icon: const Icon(CupertinoIcons.quote_bubble), + onPressed: _toggleMornyeLyrics, + ), + IconButton( + tooltip: context.l10n.nowPlayingUpNext, + color: Colors.white, + isSelected: _currentPage == 2, + style: IconButton.styleFrom( + backgroundColor: _currentPage == 2 + ? Colors.white.withValues(alpha: 0.16) + : Colors.transparent, + ), + icon: const Icon(CupertinoIcons.list_bullet), + onPressed: () => setState( + () => _currentPage = _currentPage == 2 ? 0 : 2, + ), + ), + ], + ), + ) + else if (!mornye) + _PageTabBar( + controller: _pageController, + colorScheme: colorScheme, + labels: [ + context.l10n.nowPlayingTabPlayer, + context.l10n.nowPlayingTabLyrics, + ], + ), + if (!_landscapeLyrics) const SizedBox(height: 8), ], ), ), @@ -449,6 +579,36 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ); + if (!mornye) return player; + return HeroMode( + // A route Hero renders outside the panel's gradient and slide, briefly + // exposing a detached rectangle. Keep Mornye artwork inside its panel. + enabled: false, + child: Theme( + data: MornyeTheme.build(Brightness.dark), + child: Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + child: MornyePlayerBackground( + artUri: mediaItem.artUri, + squareArtwork: motionArtwork == null, + artwork: !_landscapeLyrics && _currentPage == 0 + ? Hero( + tag: kNowPlayingArtworkHeroTag, + child: MornyePlayerArtwork( + mediaItem: mediaItem, + videoUrl: motionArtwork, + ), + ) + : null, + ), + ), + player, + ], + ), + ), + ); } /// Swipe up (player content or bottom tab strip) opens the queue sheet; a @@ -608,6 +768,313 @@ class _NowPlayingScreenState extends ConsumerState { ); } + void _toggleMornyeLyrics() { + setState(() => _currentPage = _currentPage == 1 ? 0 : 1); + if (_currentPage == 1) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + } + + Widget _mornyePlayerPage( + MediaItem mediaItem, + MusicPlayerController controller, + ColorScheme colorScheme, { + required bool squareArtwork, + }) { + final showLyrics = _landscapeLyrics || _currentPage == 1; + final showQueue = !_landscapeLyrics && _currentPage == 2; + final compactStage = showLyrics || showQueue; + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380); + return LayoutBuilder( + builder: (context, constraints) { + final screenSize = MediaQuery.sizeOf(context); + final landscape = screenSize.width > screenSize.height; + final scale = MediaQuery.textScalerOf(context).scale(17) / 17; + Widget stage({bool artworkOnly = false}) => LayoutBuilder( + builder: (context, stage) { + final compact = compactStage && !artworkOnly; + final artSize = (stage.maxWidth - 56).clamp( + 0.0, + (stage.maxHeight - 24).clamp(0.0, 360.0), + ); + return Stack( + children: [ + Positioned.fill( + top: 76, + child: IgnorePointer( + ignoring: !compact, + child: ExcludeSemantics( + excluding: !compact, + child: AnimatedSwitcher( + duration: motion, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.035), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + layoutBuilder: (currentChild, previousChildren) => + Stack( + fit: StackFit.expand, + children: [ + for (final child in previousChildren) + IgnorePointer( + child: ExcludeSemantics(child: child), + ), + ?currentChild, + ], + ), + // Keep the outgoing panel until it fades out; + // closing the queue must never substitute lyrics. + child: compact + ? KeyedSubtree( + key: ValueKey(_currentPage), + child: showQueue + ? MornyePlayerQueue( + colorScheme: colorScheme, + onShuffleLibrary: () => + _shuffleLibrary(controller), + ) + : _lyricsSection( + colorScheme, + isActive: showLyrics, + ), + ) + : null, + ), + ), + ), + ), + Positioned( + top: artworkOnly ? (stage.maxHeight - artSize) / 2 : 8, + left: artworkOnly ? (stage.maxWidth - artSize) / 2 : 28, + width: artworkOnly ? artSize : 48, + height: artworkOnly ? artSize : 48, + child: HeroMode( + enabled: artworkOnly || compactStage, + child: AnimatedSwitcher( + duration: motion, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + HeroMode( + enabled: false, + child: IgnorePointer(child: child), + ), + ?current, + ], + ), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween( + begin: 0.85, + end: 1.0, + ).animate(animation), + child: child, + ), + ), + child: !artworkOnly && !compactStage + ? null + : _artworkDragRegion( + context, + Hero( + tag: kNowPlayingArtworkHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: + (360 * + MediaQuery.devicePixelRatioOf( + context, + )) + .round(), + ), + ), + ), + ), + ), + ), + ), + Positioned( + left: 88, + right: 28, + top: 4, + child: IgnorePointer( + ignoring: !compact, + child: ExcludeSemantics( + excluding: !compact, + child: AnimatedOpacity( + opacity: compact ? 1 : 0, + duration: motion, + child: _trackHeader( + mediaItem, + colorScheme, + compact: true, + ), + ), + ), + ), + ), + ], + ); + }, + ); + + final volumeGap = + 8 + + (squareArtwork + ? (constraints.maxHeight - 480).clamp(0.0, 48.0) + : 0); + // Share the spare space above and below the transport row so it sits + // between the timeline and volume without moving either slider. + final transportShift = landscape + ? 0.0 + : ((volumeGap - 16) / 2).clamp(0.0, 20.0); + Widget controls() => _PlaybackControls( + mediaId: mediaItem.id, + duration: mediaItem.duration ?? Duration.zero, + controller: controller, + colorScheme: colorScheme, + qualityLabel: _qualityLabel(), + compact: landscape, + transportTopPadding: 16 + transportShift, + ); + + if (landscape) { + return MornyeLandscapePlayer( + artwork: stage(artworkOnly: true), + header: _trackHeader(mediaItem, colorScheme, compact: true), + lyrics: _lyricsSection(colorScheme, isActive: true), + queue: MornyePlayerQueue( + colorScheme: colorScheme, + onShuffleLibrary: () => _shuffleLibrary(controller), + ), + controls: controls(), + volume: const MornyeVolumeControl(), + ); + } + + Widget content() => Column( + children: [ + Expanded(child: stage()), + AnimatedSize( + duration: motion, + curve: Curves.easeInOutCubic, + child: compactStage + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.fromLTRB(28, 12, 28, 8), + child: _trackHeader(mediaItem, colorScheme), + ), + ), + controls(), + SizedBox(height: volumeGap - transportShift), + const MornyeVolumeControl(), + const SizedBox(height: 8), + ], + ); + + // Controls keep their intrinsic height. Small portrait screens and + // large accessibility text can scroll instead of clipping the volume. + final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120; + if (constraints.maxHeight < minHeight) { + return SingleChildScrollView( + child: SizedBox(height: minHeight, child: content()), + ); + } + return content(); + }, + ); + } + + Widget _trackHeader( + MediaItem mediaItem, + ColorScheme colorScheme, { + bool compact = false, + }) { + return Builder( + builder: (context) => Row( + children: [ + Expanded( + child: Builder( + builder: (titleContext) => InkWell( + borderRadius: BorderRadius.circular(8), + onTap: + (mediaItem.artist ?? '').trim().isEmpty && + (mediaItem.album ?? '').trim().isEmpty + ? null + : () => _showTrackNavigationMenu(titleContext, mediaItem), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 15 : 22, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + mediaItem.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 13 : 20, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + ), + MornyePlayerFavoriteButton( + key: ValueKey(mediaItem.id), + mediaItem: mediaItem, + compact: compact, + ), + Builder( + builder: (buttonContext) => IconButton( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + color: colorScheme.onSurface, + icon: const Icon(CupertinoIcons.ellipsis), + onPressed: () => _showMoreActions( + context: context, + mediaItem: mediaItem, + source: mediaItem.extras?['source']?.toString() ?? '', + colorScheme: colorScheme, + anchor: mornyeMenuAnchor(buttonContext), + ), + ), + ), + ], + ), + ); + } + /// Title/artist, seek slider, and transport buttons — shared by the /// portrait column and the landscape right pane. List _metadataAndControls( @@ -632,29 +1099,37 @@ class _NowPlayingScreenState extends ConsumerState { child: child, ), ), - child: Column( + child: Row( key: ValueKey(mediaItem.id), children: [ - ExplicitTrackTitle( - title: mediaItem.title, - explicit: _isExplicit(mediaItem), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + mediaItem.artist ?? '', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - Text( - mediaItem.artist ?? '', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ], ), @@ -663,6 +1138,7 @@ class _NowPlayingScreenState extends ConsumerState { const SizedBox(height: 24), _PlaybackControls( key: ValueKey(mediaItem.id), + mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, colorScheme: colorScheme, @@ -732,10 +1208,37 @@ class _NowPlayingScreenState extends ConsumerState { } } + Future _showTrackNavigationMenu( + BuildContext titleContext, + MediaItem mediaItem, + ) async { + final action = await showMornyeContextMenu( + context: titleContext, + builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem), + ); + if (!mounted) return; + if (action == 'album') { + await _goToCurrentAlbum( + mediaItem: mediaItem, + source: mediaItem.extras?['source']?.toString() ?? '', + ); + } else if (action == 'artist') { + final track = ref.read(playerCollectionTrackProvider(mediaItem)).value; + await navigateToArtist( + context, + artistName: mediaItem.artist ?? '', + artistId: track?.artistId, + extensionId: track?.source, + ); + } + } + Future _showMoreActions({ + required BuildContext context, required MediaItem mediaItem, required String source, required ColorScheme colorScheme, + Rect? anchor, }) async { final controller = ref.read(musicPlayerControllerProvider); final sleepTimerEndsAt = controller.sleepTimerEndsAt; @@ -746,58 +1249,69 @@ class _NowPlayingScreenState extends ConsumerState { context, ).formatTimeOfDay(TimeOfDay.fromDateTime(sleepTimerEndsAt)), ); - final action = await showAppBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - title: mediaItem.title, - subtitle: mediaItem.artist, - builder: (sheetContext) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: SettingsGroup( - children: [ - if ((mediaItem.album ?? '').trim().isNotEmpty) - SettingsItem( - icon: Icons.album_outlined, - title: sheetContext.l10n.homeGoToAlbum, - onTap: () => Navigator.of(sheetContext).pop('album'), + final action = context.isMornye + ? await showMornyeContextMenu( + context: context, + anchor: anchor, + builder: (_) => MornyePlayerActionsSheet( + mediaItem: mediaItem, + sleepTimerSubtitle: sleepTimerSubtitle, + ), + ) + : await showAppBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + title: mediaItem.title, + subtitle: mediaItem.artist, + builder: (sheetContext) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SettingsGroup( + children: [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + SettingsItem( + icon: Icons.album_outlined, + title: sheetContext.l10n.homeGoToAlbum, + onTap: () => Navigator.of(sheetContext).pop('album'), + ), + SettingsItem( + icon: Icons.info_outline, + title: sheetContext.l10n.nowPlayingDetails, + onTap: () => Navigator.of(sheetContext).pop('details'), + ), + SettingsItem( + icon: Icons.bedtime_outlined, + title: sheetContext.l10n.nowPlayingSleepTimer, + subtitle: sleepTimerSubtitle, + onTap: () => Navigator.of(sheetContext).pop('sleepTimer'), + ), + SettingsItem( + icon: Icons.open_in_new, + title: sheetContext.l10n.nowPlayingOpenInExternalPlayer, + trailing: Icon( + Icons.open_in_new, + size: 18, + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + showDivider: false, + onTap: () => Navigator.of(sheetContext).pop('external'), + ), + ], ), - SettingsItem( - icon: Icons.info_outline, - title: sheetContext.l10n.nowPlayingDetails, - onTap: () => Navigator.of(sheetContext).pop('details'), ), - SettingsItem( - icon: Icons.bedtime_outlined, - title: sheetContext.l10n.nowPlayingSleepTimer, - subtitle: sleepTimerSubtitle, - onTap: () => Navigator.of(sheetContext).pop('sleepTimer'), - ), - SettingsItem( - icon: Icons.open_in_new, - title: sheetContext.l10n.nowPlayingOpenInExternalPlayer, - trailing: Icon( - Icons.open_in_new, - size: 18, - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), - showDivider: false, - onTap: () => Navigator.of(sheetContext).pop('external'), - ), - ], - ), - ), - ); - if (!mounted) return; + ); + if (!mounted || !context.mounted) return; switch (action) { case 'album': await _goToCurrentAlbum(mediaItem: mediaItem, source: source); break; case 'details': - _showDetailsSheet(colorScheme); + _showDetailsSheet(context, colorScheme); break; case 'sleepTimer': - await _showSleepTimerSheet(controller, colorScheme); + await _showSleepTimerSheet(context, controller, colorScheme); break; case 'external': await _openExternally(source); @@ -806,6 +1320,7 @@ class _NowPlayingScreenState extends ConsumerState { } Future _showSleepTimerSheet( + BuildContext context, MusicPlayerController controller, ColorScheme colorScheme, ) async { @@ -838,7 +1353,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ); - if (!mounted || selection == null) return; + if (!mounted || !context.mounted || selection == null) return; if (selection == 'off') { controller.cancelSleepTimer(); @@ -970,6 +1485,10 @@ class _NowPlayingScreenState extends ConsumerState { } void _showQueueSheet(ColorScheme colorScheme) { + if (context.isMornye) { + setState(() => _currentPage = 2); + return; + } if (_queueSheetShowing) return; _queueSheetShowing = true; showModalBottomSheet( @@ -1154,34 +1673,55 @@ class _NowPlayingScreenState extends ConsumerState { ).whenComplete(() => _queueSheetShowing = false); } - void _showDetailsSheet(ColorScheme colorScheme) { - final meta = _metadata; + void _showDetailsSheet(BuildContext context, ColorScheme colorScheme) { + final mornye = context.isMornye; + final mediaItem = ref.read(currentMediaItemProvider).value; + final fallback = { + if (mediaItem != null) ...{ + 'title': mediaItem.title, + 'artist': mediaItem.artist ?? '', + 'album': mediaItem.album ?? '', + ...playbackAudioMetadataFromMediaItem(mediaItem), + }, + ...?_metadata, + }; + final source = mediaItem?.extras?['source']?.toString() ?? ''; + final resolved = mediaItem?.extras?['resolvedSource']?.toString().trim(); + final path = resolved != null && resolved.isNotEmpty ? resolved : source; + // Restored Android SAF items initially carry only queue metadata. Read + // their tags on demand, even if playback has not resolved a local copy. + final metadataFuture = path.isEmpty || _loadedMetadataPath == path + ? Future.value(fallback) + : readPlaybackFileMetadataWithRetry(path) + .then((meta) => mergePlaybackFileMetadata(fallback, meta)) + .catchError((Object error) { + _log.w('Failed to read details metadata: $error'); + return fallback; + }); showModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, - backgroundColor: colorScheme.surfaceContainerHigh, + useRootNavigator: mornye, + showDragHandle: !mornye, + backgroundColor: mornye + ? Colors.transparent + : colorScheme.surfaceContainerHigh, builder: (context) { return DraggableScrollableSheet( expand: false, - initialChildSize: 0.6, + initialChildSize: mornye ? 0.75 : 0.6, maxChildSize: 0.9, minChildSize: 0.4, builder: (context, scrollController) { - if (meta == null) { - return Center( - child: Text( - context.l10n.nowPlayingNoMetadata, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ); - } - return _MetadataList( - meta: meta, - colorScheme: colorScheme, - scrollController: scrollController, + return FutureBuilder>( + future: metadataFuture, + initialData: fallback, + builder: (context, snapshot) => _MetadataList( + meta: snapshot.data ?? fallback, + colorScheme: colorScheme, + scrollController: scrollController, + mediaItem: mediaItem, + ), ); }, ); @@ -1191,24 +1731,35 @@ class _NowPlayingScreenState extends ConsumerState { } class _PlaybackControls extends ConsumerWidget { + final String mediaId; final Duration duration; final MusicPlayerController controller; final ColorScheme colorScheme; final String? qualityLabel; + final bool compact; + final double transportTopPadding; const _PlaybackControls({ super.key, + required this.mediaId, required this.duration, required this.controller, required this.colorScheme, required this.qualityLabel, + this.compact = false, + this.transportTopPadding = 16, }); @override Widget build(BuildContext context, WidgetRef ref) { + final mornye = context.isMornye; final position = ref.watch(playbackPositionProvider); + final elapsedSeconds = position.inSeconds; final isPlaying = ref.watch(playbackPlayingProvider); final isLoading = ref.watch(playbackLoadingProvider); + final timeStyle = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant); final shuffleOn = ref.watch( playbackStateProvider.select( (s) => s.value?.shuffleMode == AudioServiceShuffleMode.all, @@ -1228,11 +1779,11 @@ class _PlaybackControls extends ConsumerWidget { SliderTheme( data: SliderThemeData( trackHeight: 4, - activeTrackColor: colorScheme.primary, + activeTrackColor: mornye ? Colors.white : colorScheme.primary, inactiveTrackColor: colorScheme.onSurface.withValues( alpha: 0.18, ), - thumbColor: colorScheme.primary, + thumbColor: mornye ? Colors.white : colorScheme.primary, // A 7dp thumb was hard to grab; 10dp with a 24dp overlay // gives the drag gesture a full-size target. thumbShape: const RoundSliderThumbShape( @@ -1243,6 +1794,7 @@ class _PlaybackControls extends ConsumerWidget { ), ), child: PlaybackSeekSlider( + key: ValueKey(mediaId), position: position, duration: duration, onSeek: controller.seek, @@ -1252,12 +1804,14 @@ class _PlaybackControls extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - Text( - formatClock(position.inSeconds), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + if (mornye) + MornyePlaybackTime( + key: ValueKey('elapsed:$mediaId'), + seconds: elapsedSeconds, + style: timeStyle, + ) + else + Text(formatClock(elapsedSeconds), style: timeStyle), Expanded( child: Center( child: _QualityBadge( @@ -1266,93 +1820,140 @@ class _PlaybackControls extends ConsumerWidget { ), ), ), - Text( - formatClock(duration.inSeconds), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + if (mornye) + MornyePlaybackTime( + key: ValueKey('remaining:$mediaId'), + // Subtract whole seconds so both labels roll together, + // even when the track duration includes milliseconds. + seconds: (duration.inSeconds - elapsedSeconds).clamp( + 0, + duration.inSeconds, + ), + remaining: true, + style: timeStyle, + ) + else + Text(formatClock(duration.inSeconds), style: timeStyle), ], ), ), ], ), ), - const SizedBox(height: 16), + SizedBox(height: compact ? 8 : transportTopPadding), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: mornye + ? MainAxisAlignment.spaceEvenly + : MainAxisAlignment.center, children: [ - IconButton( - iconSize: 24, - tooltip: shuffleOn - ? context.l10n.nowPlayingShuffleOn - : context.l10n.nowPlayingPlayInOrder, - color: shuffleOn - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - icon: const Icon(Icons.shuffle), - onPressed: () => controller.setShuffle(!shuffleOn), - ), - const SizedBox(width: 8), - IconButton( - iconSize: 44, - tooltip: context.l10n.nowPlayingPreviousTrack, - icon: const Icon(Icons.skip_previous), - onPressed: controller.previous, - ), - const SizedBox(width: 20), - Container( - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, + if (!mornye) + IconButton( + iconSize: 24, + tooltip: shuffleOn + ? context.l10n.nowPlayingShuffleOn + : context.l10n.nowPlayingPlayInOrder, + color: shuffleOn + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + icon: const Icon(Icons.shuffle), + onPressed: () => controller.setShuffle(!shuffleOn), ), - child: IconButton( + if (!mornye) const SizedBox(width: 8), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.backward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + onPressed: controller.previous, + ) + else + IconButton( iconSize: 44, - padding: const EdgeInsets.all(12), - color: colorScheme.onPrimary, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + icon: const Icon(Icons.skip_previous), + onPressed: controller.previous, + ), + SizedBox(width: mornye ? 12 : 20), + if (mornye) + MornyePlaybackButton( + icon: isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, - icon: isLoading - ? const SizedBox.square( - dimension: 32, - child: CircularProgressIndicator(strokeWidth: 3), - ) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: isLoading - ? null - : () => controller.togglePlayPause(isPlaying), + color: colorScheme.onSurface, + iconSize: 44, + padding: const EdgeInsets.all(12), + loading: isLoading, + onPressed: () => controller.togglePlayPause(isPlaying), + ) + else + Container( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: IconButton( + iconSize: 44, + padding: const EdgeInsets.all(12), + color: colorScheme.onPrimary, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + icon: isLoading + ? const SizedBox.square( + dimension: 32, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), + ), ), - ), - const SizedBox(width: 20), - IconButton( - iconSize: 44, - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), - const SizedBox(width: 8), - IconButton( - iconSize: 24, - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, - AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, - _ => context.l10n.nowPlayingRepeatAll, - }, - color: repeatMode == AudioServiceRepeatMode.none - ? colorScheme.onSurfaceVariant - : colorScheme.primary, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? Icons.repeat_one - : Icons.repeat, + SizedBox(width: mornye ? 12 : 20), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.forward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + onPressed: controller.next, + ) + else + IconButton( + iconSize: 44, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + icon: const Icon(Icons.skip_next), + onPressed: controller.next, + ), + if (!mornye) const SizedBox(width: 8), + if (!mornye) + IconButton( + iconSize: 24, + tooltip: switch (repeatMode) { + AudioServiceRepeatMode.one => + context.l10n.nowPlayingRepeatOne, + AudioServiceRepeatMode.none => + context.l10n.nowPlayingRepeatOff, + _ => context.l10n.nowPlayingRepeatAll, + }, + color: repeatMode == AudioServiceRepeatMode.none + ? colorScheme.onSurfaceVariant + : colorScheme.primary, + icon: Icon( + repeatMode == AudioServiceRepeatMode.one + ? Icons.repeat_one + : Icons.repeat, + ), + onPressed: () => controller.setRepeatMode(switch (repeatMode) { + AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }), ), - onPressed: () => controller.setRepeatMode(switch (repeatMode) { - AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, - AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }), - ), ], ), ], @@ -1387,6 +1988,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Duration _activeTransitionPosition = Duration.zero; bool _playing = false; bool _loading = false; + bool _hasStarted = false; bool _userScrolling = false; static const double _estimatedLyricExtent = 64; @@ -1394,6 +1996,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { void initState() { super.initState(); _resetLineKeys(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); _syncPositionSubscription(); } @@ -1410,6 +2017,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } void _resetLineKeys() { + _hasStarted = false; _lineKeys = List.generate( widget.lyrics.lines.length, (index) => GlobalKey(debugLabel: 'lyric-line-$index'), @@ -1430,7 +2038,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final position = ref.read(playbackPositionProvider); _playing = ref.read(playbackPlayingProvider); _loading = ref.read(playbackLoadingProvider); - _active = LyricsParser.activeIndex(widget.lyrics.lines, position); + _active = _activeIndexAt(position); _activeTransitionPosition = position; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_maybeAutoScroll(_active)); @@ -1439,7 +2047,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _positionSubscription = ref.listenManual( playbackPositionProvider, (previous, next) { - final active = LyricsParser.activeIndex(widget.lyrics.lines, next); + final active = _activeIndexAt(next); if (active != _active) _setActiveLine(active, position: next); _scheduleNextLine(next); }, @@ -1449,17 +2057,37 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { next, ) { _playing = next; - _scheduleNextLine(ref.read(playbackPositionProvider)); + final position = ref.read(playbackPositionProvider); + _setActiveLine(_activeIndexAt(position), position: position); + _scheduleNextLine(position); }); _loadingSubscription = ref.listenManual(playbackLoadingProvider, ( previous, next, ) { _loading = next; - _scheduleNextLine(ref.read(playbackPositionProvider)); + final position = ref.read(playbackPositionProvider); + _setActiveLine(_activeIndexAt(position), position: position); + _scheduleNextLine(position); }); } + int _activeIndexAt(Duration position) { + if (context.isMornye) { + // Read one transport snapshot: playing/loading derived providers can + // notify separately during the same playback event. + final playback = ref.read(playbackStateProvider).value; + if (playback?.processingState == AudioProcessingState.loading || + playback?.processingState == AudioProcessingState.buffering) { + return -1; + } + _hasStarted = + _hasStarted || playback?.playing == true || position > Duration.zero; + if (!_hasStarted) return -1; + } + return LyricsParser.activeIndex(widget.lyrics.lines, position); + } + void _setActiveLine(int active, {required Duration position}) { if (!mounted || active == _active) return; setState(() { @@ -1552,6 +2180,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Widget build(BuildContext context) { final lines = widget.lyrics.lines; final active = _active; + final mornye = context.isMornye; + final highContrast = MediaQuery.highContrastOf(context); + final blurLyrics = + mornye && + !highContrast && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 280); return NotificationListener( onNotification: (notification) { @@ -1579,7 +2217,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final isActive = index == active; final isPast = index < active; - final color = isActive + final color = mornye + ? Colors.white + : isActive ? widget.colorScheme.onSurface : isPast ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) @@ -1600,14 +2240,15 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } else { content = Text( text, - textAlign: TextAlign.center, + textAlign: mornye ? TextAlign.start : TextAlign.center, style: - (isActive + (mornye || isActive ? Theme.of(context).textTheme.headlineSmall : Theme.of(context).textTheme.titleLarge) ?.copyWith( - height: 1.4, - fontWeight: isActive + height: mornye ? 1.3 : 1.4, + fontSize: mornye ? 28 : null, + fontWeight: mornye || isActive ? FontWeight.bold : FontWeight.w500, color: color, @@ -1621,23 +2262,49 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { switchOutCurve: Curves.easeInCubic, child: KeyedSubtree( key: ValueKey(isActive && line.hasWordTiming), - child: content, + child: mornye + ? SizedBox(width: double.infinity, child: content) + : content, ), ); + if (mornye) { + final distance = (index - active).abs(); + final sigma = !blurLyrics || isActive + ? 0.0 + : active < 0 + ? 2.8 + : distance == 1 + ? 0.6 + : distance == 2 + ? 1.8 + : 2.8; + content = TweenAnimationBuilder( + tween: Tween(end: sigma), + duration: motion, + child: RepaintBoundary(child: content), + builder: (context, value, child) => ImageFiltered( + enabled: value > 0, + imageFilter: ImageFilter.blur(sigmaX: value, sigmaY: value), + child: child, + ), + ); + } return Padding( key: _lineKeys[index], - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric(vertical: mornye ? 16 : 10), child: GestureDetector( onTap: () => ref.read(musicPlayerControllerProvider).seek(line.time), child: AnimatedScale( - scale: isActive ? 1.0 : 0.96, + scale: mornye || isActive ? 1.0 : 0.96, alignment: Alignment.center, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic, child: AnimatedOpacity( - opacity: isActive ? 1.0 : (isPast ? 0.55 : 0.85), + opacity: mornye + ? (isActive || highContrast ? 1 : 0.48) + : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), duration: const Duration(milliseconds: 280), child: content, ), @@ -1805,9 +2472,10 @@ class _WordHighlightedLyricLineState Widget _buildHighlightedLine(BuildContext context) { final highlightedColor = widget.colorScheme.onSurface; - final pendingColor = widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ); + final mornye = context.isMornye; + final pendingColor = mornye + ? Colors.white.withValues(alpha: 0.4) + : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6); final segments = []; final starts = []; final ends = []; @@ -1825,7 +2493,12 @@ class _WordHighlightedLyricLineState currentPosition: _currentPosition, repaint: _animationClock, style: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) - .copyWith(height: 1.4, fontWeight: FontWeight.bold), + .copyWith( + fontSize: mornye ? 28 : null, + height: mornye ? 1.3 : 1.4, + fontWeight: FontWeight.bold, + ), + textAlign: mornye ? TextAlign.start : TextAlign.center, pendingColor: pendingColor, highlightedColor: highlightedColor, semanticsLabel: widget.line.text, @@ -1840,6 +2513,7 @@ class _SweepingTimedLyricText extends StatefulWidget { final Duration Function() currentPosition; final Listenable repaint; final TextStyle style; + final TextAlign textAlign; final Color pendingColor; final Color highlightedColor; final String semanticsLabel; @@ -1851,6 +2525,7 @@ class _SweepingTimedLyricText extends StatefulWidget { required this.currentPosition, required this.repaint, required this.style, + required this.textAlign, required this.pendingColor, required this.highlightedColor, required this.semanticsLabel, @@ -1866,6 +2541,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { TextPainter? _highlightedPainter; String? _cachedText; TextStyle? _cachedStyle; + TextAlign? _cachedAlignment; TextDirection? _cachedDirection; TextScaler? _cachedScaler; Locale? _cachedLocale; @@ -1880,6 +2556,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { ) { if (_cachedText == text && _cachedStyle == widget.style && + _cachedAlignment == widget.textAlign && _cachedDirection == textDirection && _cachedScaler == textScaler && _cachedLocale == locale && @@ -1895,7 +2572,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { text: text, style: widget.style.copyWith(color: widget.pendingColor), ), - textAlign: TextAlign.center, + textAlign: widget.textAlign, textDirection: textDirection, textScaler: textScaler, locale: locale, @@ -1905,13 +2582,14 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { text: text, style: widget.style.copyWith(color: widget.highlightedColor), ), - textAlign: TextAlign.center, + textAlign: widget.textAlign, textDirection: textDirection, textScaler: textScaler, locale: locale, ); _cachedText = text; _cachedStyle = widget.style; + _cachedAlignment = widget.textAlign; _cachedDirection = textDirection; _cachedScaler = textScaler; _cachedLocale = locale; @@ -1953,18 +2631,26 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { ? constraints.maxWidth : pendingPainter.width; final height = pendingPainter.height; - final segmentBoxes = >[]; + final segmentBoxes = >[]; var segmentOffset = 0; for (final segment in widget.segments) { final segmentEnd = segmentOffset + segment.length; - segmentBoxes.add( - highlightedPainter.getBoxesForSelection( - TextSelection( - baseOffset: segmentOffset, - extentOffset: segmentEnd, - ), - ), - ); + final boxes = + highlightedPainter + .getBoxesForSelection( + TextSelection( + baseOffset: segmentOffset, + extentOffset: segmentEnd, + ), + boxHeightStyle: BoxHeightStyle.max, + ) + .map((box) => box.toRect()) + .toList() + ..sort((a, b) { + final row = a.top.compareTo(b.top); + return row == 0 ? a.left.compareTo(b.left) : row; + }); + segmentBoxes.add(boxes); segmentOffset = segmentEnd; } @@ -1989,7 +2675,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { } class _TimedLyricSweepPainter extends CustomPainter { - final List> segmentBoxes; + final List> segmentBoxes; final List starts; final List ends; final Duration Function() currentPosition; @@ -2022,13 +2708,21 @@ class _TimedLyricSweepPainter extends CustomPainter { ) : 0.0; if (value > 0) { - for (final box in segmentBoxes[index]) { - final rect = box.toRect(); - if (value >= 1) { - completedPath.addRect(rect); + final boxes = segmentBoxes[index]; + // Font fallback and wrapping can split one timed segment into several + // boxes. Consume its progress once, instead of lighting every box at + // the same time (which starts highlights in the middle of the text). + var revealWidth = + boxes.fold(0, (width, box) => width + box.width) * value; + for (final box in boxes) { + if (revealWidth <= 0) break; + if (box.width <= 0) continue; + if (revealWidth >= box.width) { + completedPath.addRect(box); } else { - partialBoxes.add((rect, value)); + partialBoxes.add((box, revealWidth / box.width)); } + revealWidth -= box.width; } } } @@ -2089,11 +2783,13 @@ class _MetadataList extends StatelessWidget { final Map meta; final ColorScheme colorScheme; final ScrollController scrollController; + final MediaItem? mediaItem; const _MetadataList({ required this.meta, required this.colorScheme, required this.scrollController, + this.mediaItem, }); @override @@ -2127,6 +2823,14 @@ class _MetadataList extends StatelessWidget { ), ].where((r) => r.$2.trim().isNotEmpty && r.$2 != '0').toList(); + if (context.isMornye) { + return MornyePlayerDetailsSheet( + rows: rows, + scrollController: scrollController, + mediaItem: mediaItem, + ); + } + final textTheme = Theme.of(context).textTheme; return ListView( @@ -2309,10 +3013,12 @@ class _QualityBadge extends StatelessWidget { if (text == null || text.isEmpty) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(10), - ), + decoration: context.isMornye + ? null + : BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index a353fd65..b5036ebe 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; @@ -15,7 +16,6 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -26,6 +26,8 @@ import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -253,8 +255,21 @@ class _PlaylistScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: _coverUrl, + neutralActions: true, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final bottomPadding = MediaQuery.paddingOf(context).bottom; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; pruneSelection({ for (var index = 0; index < _tracks.length; index++) @@ -265,7 +280,6 @@ class _PlaylistScreenState extends ConsumerState scrollController: scrollController, isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, - bottomInset: context.navBarBottomInset, selectionBar: _buildSelectionBottomBar( context, colorScheme, @@ -310,6 +324,8 @@ class _PlaylistScreenState extends ConsumerState return AlbumDetailHeader( title: _playlistName, + immersive: context.isMornye, + squareArtwork: !hasMotion, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, background: hasMotion @@ -343,11 +359,24 @@ class _PlaylistScreenState extends ConsumerState actions: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible(child: _buildDownloadAllCenterButton(context)), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), + if (context.isMornye) ...[ + _buildLoveAllButton(), + const SizedBox(width: 16), + Flexible( + child: SizedBox( + width: 172, + child: _buildDownloadAllCenterButton(context), + ), + ), + const SizedBox(width: 16), + _buildAddToPlaylistButton(context), + ] else ...[ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible(child: _buildDownloadAllCenterButton(context)), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], ], ), ); @@ -363,10 +392,12 @@ class _PlaylistScreenState extends ConsumerState Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { if (_isLoading) { - return const SliverToBoxAdapter( + return SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(16), - child: TrackListSkeleton(itemCount: 8), + padding: context.isMornye + ? EdgeInsets.zero + : const EdgeInsets.all(16), + child: const TrackListSkeleton(itemCount: 8), ), ); } @@ -488,6 +519,7 @@ class _PlaylistScreenState extends ConsumerState return HeaderCircleButton( icon: allLoved ? Icons.favorite : Icons.favorite_border, + tonal: true, iconColor: allLoved ? Theme.of(context).colorScheme.error : null, tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved @@ -498,8 +530,13 @@ class _PlaylistScreenState extends ConsumerState Widget _buildDownloadAllCenterButton(BuildContext context) { return HeaderFilledButton( - icon: Icons.download_rounded, - label: context.l10n.downloadAllCount(_tracks.length), + icon: context.isMornye + ? CupertinoIcons.arrow_down_circle_fill + : Icons.download_rounded, + tonal: true, + label: context.isMornye + ? context.l10n.dialogDownload + : context.l10n.downloadAllCount(_tracks.length), onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), ); } @@ -507,6 +544,7 @@ class _PlaylistScreenState extends ConsumerState Widget _buildAddToPlaylistButton(BuildContext context) { return HeaderCircleButton( icon: Icons.playlist_add, + tonal: true, tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks.isEmpty ? null diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index e04c813a..64f66cb8 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2,13 +2,19 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; @@ -30,6 +36,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; @@ -775,7 +782,7 @@ class _QueueTabState extends ConsumerState { String? tempMetadata = _filterMetadata; String tempSortMode = _sortMode; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -831,13 +838,13 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempSource == null, onSelected: (_) => setSheetState(() => tempSource = null), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterDownloaded, ), @@ -846,7 +853,7 @@ class _QueueTabState extends ConsumerState { () => tempSource = 'downloaded', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterLocal), selected: tempSource == 'local', onSelected: (_) => @@ -865,13 +872,13 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempQuality == null, onSelected: (_) => setSheetState(() => tempQuality = null), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterQualityHiRes, ), @@ -879,7 +886,7 @@ class _QueueTabState extends ConsumerState { onSelected: (_) => setSheetState(() => tempQuality = 'hires'), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterQualityCD, ), @@ -887,7 +894,7 @@ class _QueueTabState extends ConsumerState { onSelected: (_) => setSheetState(() => tempQuality = 'cd'), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterQualityLossy, ), @@ -908,7 +915,7 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempFormat == null, onSelected: (_) => @@ -916,7 +923,7 @@ class _QueueTabState extends ConsumerState { ), for (final format in availableFormats.toList()..sort()) - FilterChip( + AppChoiceChip( label: Text(format.toUpperCase()), selected: tempFormat == format, onSelected: (_) => @@ -936,13 +943,13 @@ class _QueueTabState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempMetadata == null, onSelected: (_) => setSheetState(() => tempMetadata = null), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataComplete, ), @@ -951,7 +958,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'complete', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataMissingAny, ), @@ -960,7 +967,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-any', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataMissingYear, ), @@ -969,7 +976,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-year', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -980,7 +987,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-genre', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -992,7 +999,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-album-artist', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1004,7 +1011,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-track-number', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1015,7 +1022,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-disc-number', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1026,7 +1033,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-artist', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1038,7 +1045,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'incorrect-isrc-format', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataMissingIsrc, ), @@ -1047,7 +1054,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-isrc', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1058,7 +1065,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-label', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1082,7 +1089,7 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortLatest, ), @@ -1091,7 +1098,7 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'latest', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortOldest, ), @@ -1100,33 +1107,33 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'oldest', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortTitleAZ), selected: tempSortMode == 'a-z', onSelected: (_) => setSheetState(() => tempSortMode = 'a-z'), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortTitleZA), selected: tempSortMode == 'z-a', onSelected: (_) => setSheetState(() => tempSortMode = 'z-a'), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortArtistAZ), selected: tempSortMode == 'artist-asc', onSelected: (_) => setSheetState( () => tempSortMode = 'artist-asc', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortArtistZA), selected: tempSortMode == 'artist-desc', onSelected: (_) => setSheetState( () => tempSortMode = 'artist-desc', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortAlbumAsc, ), @@ -1135,7 +1142,7 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'album-asc', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortAlbumDesc, ), @@ -1144,21 +1151,21 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'album-desc', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortDateNewest), selected: tempSortMode == 'release-newest', onSelected: (_) => setSheetState( () => tempSortMode = 'release-newest', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortDateOldest), selected: tempSortMode == 'release-oldest', onSelected: (_) => setSheetState( () => tempSortMode = 'release-oldest', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortGenreAsc, ), @@ -1167,7 +1174,7 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'genre-asc', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortGenreDesc, ), @@ -1182,7 +1189,8 @@ class _QueueTabState extends ConsumerState { SizedBox( width: double.infinity, - child: FilledButton( + child: AppDialogAction( + filled: true, onPressed: () { setState(() { _filterSource = tempSource; @@ -1443,12 +1451,11 @@ class _QueueTabState extends ConsumerState { isLibraryPageLoading: isLibraryPageLoading, ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; final selectionItems = getFilterData( historyFilterMode, ).filteredUnifiedItems; if (_isSelectionMode || _isPlaylistSelectionMode) { + final bottomPadding = MediaQuery.paddingOf(context).bottom; WidgetsBinding.instance.addPostFrameCallback((_) { if (_isSelectionMode) { _syncSelectionOverlay( @@ -1494,53 +1501,14 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: GestureDetector( onTap: () {}, - child: TextField( + child: AppSearchField( controller: _searchController, focusNode: _searchFocusNode, - autofocus: false, - canRequestFocus: true, - decoration: InputDecoration( - hintText: context.l10n.historySearchHint, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - tooltip: context.l10n.dialogClear, - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _clearSearch(); - FocusScope.of(context).unfocus(); - }, - ) - : null, - filled: true, - fillColor: settingsGroupColor(context), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - ), + hintText: context.l10n.historySearchHint, + clearTooltip: context.l10n.dialogClear, onChanged: _onSearchChanged, - onTapOutside: (_) { + onClear: () { + _clearSearch(); FocusScope.of(context).unfocus(); }, ), @@ -1567,6 +1535,9 @@ class _QueueTabState extends ConsumerState { size: 48, child: SingleChildScrollView( scrollDirection: Axis.horizontal, + padding: context.isMornye + ? const EdgeInsets.symmetric(vertical: 4) + : EdgeInsets.zero, child: Row( children: [ _FilterChip( @@ -1635,7 +1606,6 @@ class _QueueTabState extends ConsumerState { : false, isPageLoading: isLibraryPageLoading, inMemoryHistoryItems: inMemoryHistoryItems, - bottomInset: bottomInset, ); }, ), @@ -1753,17 +1723,18 @@ class _QueueTabState extends ConsumerState { BuildContext context, DownloadItem item, ) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.cancelDownloadTitle), content: Text(context.l10n.cancelDownloadContent(item.track.name)), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(ctx).pop(false), child: Text(context.l10n.cancelDownloadKeep), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.of(ctx).pop(true), child: Text(context.l10n.dialogCancel), ), @@ -1799,9 +1770,9 @@ class _QueueTabState extends ConsumerState { : (item.errorMessage.trim().isNotEmpty ? _localizedDownloadError(context, item.errorMessage) : context.l10n.updateDownloadFailed); - final action = await showDialog( + final action = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(title), content: SingleChildScrollView( child: Column( @@ -1823,22 +1794,25 @@ class _QueueTabState extends ConsumerState { ), ), actions: [ - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.of(ctx).pop('remove'), style: TextButton.styleFrom(foregroundColor: colorScheme.error), child: Text(context.l10n.dialogRemove), ), - TextButton( + AppDialogAction( onPressed: () => Navigator.of(ctx).pop(), child: Text(context.l10n.dialogCancel), ), if (isFolderAccessLost) - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.of(ctx).pop('reselect'), child: Text(context.l10n.downloadFolderReselect), ) else - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.of(ctx).pop('retry'), child: Text(context.l10n.dialogRetry), ), diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index e79cd7d5..9f786121 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -42,7 +42,6 @@ extension _QueueTabFilterWidgets on _QueueTabState { required bool hasMoreLibrary, required bool isPageLoading, required List inMemoryHistoryItems, - double bottomInset = 0, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; @@ -749,7 +748,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 100 : 16), ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ); @@ -777,17 +776,19 @@ extension _QueueTabFilterWidgets on _QueueTabState { WidgetRef ref, ColorScheme colorScheme, ) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.queueClearAll), content: Text(context.l10n.queueClearAllMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogClear), diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 8b910086..450f044e 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -101,7 +101,8 @@ extension _QueueTabItemWidgets on _QueueTabState { SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + isDestructive: true, onPressed: selectedCount > 0 ? () => _deleteSelected(unifiedItems) : null, @@ -148,19 +149,20 @@ extension _QueueTabItemWidgets on _QueueTabState { direction: DismissDirection.endToStart, confirmDismiss: isActive ? (_) async { - return await showDialog( + return await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.cancelDownloadTitle), content: Text( context.l10n.cancelDownloadContent(item.track.name), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(ctx).pop(false), child: Text(context.l10n.cancelDownloadKeep), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.of(ctx).pop(true), child: Text(context.l10n.dialogCancel), ), diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 321a70fa..b674c047 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -181,9 +181,9 @@ extension _QueueTabSelectionActions on _QueueTabState { return; } - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.dialogDownloadAllTitle), content: Text( ctx.l10n.dialogDownloadPlaylistsMessage( @@ -192,11 +192,12 @@ extension _QueueTabSelectionActions on _QueueTabState { ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.l10n.dialogDownload), ), @@ -279,17 +280,20 @@ extension _QueueTabSelectionActions on _QueueTabState { Future _deleteSelectedPlaylists(BuildContext context) async { final count = _selectedPlaylistIds.length; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.collectionDeletePlaylist), content: Text(ctx.l10n.collectionDeletePlaylistsMessage(count)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, @@ -400,17 +404,20 @@ extension _QueueTabSelectionActions on _QueueTabState { Future _deleteSelected(List allItems) async { final count = _selectedIds.length; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.dialogDeleteSelectedTitle), content: Text(context.l10n.dialogDeleteSelectedMessage(count)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index 7ef342a0..62bc9292 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -42,7 +42,7 @@ class _CollectionEntry { _CollectionEntry._(_CollectionEntryType.playlist, index); } -class _FilterChip extends StatelessWidget { +class _FilterChip extends ConsumerWidget { final String label; final int count; final bool isSelected; @@ -56,36 +56,89 @@ class _FilterChip extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; - return FilterChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: mornye + ? Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ) + : null, + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, + ), + ), + ], + ); + + if (mornye) { + return Semantics( + button: true, + selected: isSelected, + label: '$label, $count', + excludeSemantics: true, + onTap: onTap, + child: MornyeGlass( + radius: 22, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : context.tokens.motionFast, + constraints: const BoxConstraints(minHeight: 44), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.10) + : Colors.transparent, + borderRadius: BorderRadius.circular(22), + ), + child: content, ), ), ), - ], - ), + ), + ); + } + + return FilterChip( + label: content, selected: isSelected, onSelected: (_) => onTap(), showCheckmark: false, diff --git a/lib/screens/repo/extension_details_screen.dart b/lib/screens/repo/extension_details_screen.dart index f947a80c..60cccfe9 100644 --- a/lib/screens/repo/extension_details_screen.dart +++ b/lib/screens/repo/extension_details_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -7,6 +9,10 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/extension_repo_card.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; class ExtensionDetailsScreen extends ConsumerStatefulWidget { final RepoExtension extension; @@ -130,7 +136,9 @@ class _ExtensionDetailsScreenState padding: EdgeInsets.only(left: edgeInset), child: IconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), + icon: Icon( + context.isMornye ? CupertinoIcons.chevron_back : Icons.arrow_back, + ), onPressed: () => Navigator.pop(context), ), ), @@ -145,7 +153,9 @@ class _ExtensionDetailsScreenState return Container( color: colorScheme.surfaceContainerHighest, child: Icon( - _getCategoryIcon(ext.category), + context.isMornye + ? mornyeIconFor(_getCategoryIcon(ext.category)) + : _getCategoryIcon(ext.category), size: size, color: colorScheme.onSurfaceVariant, ), @@ -163,12 +173,8 @@ class _ExtensionDetailsScreenState padding: const EdgeInsets.all(16) + EdgeInsets.symmetric(horizontal: wideListInset(context)), - child: Card( - elevation: 0, + child: ExtensionRepoCard( color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -232,7 +238,11 @@ class _ExtensionDetailsScreenState if (ext.hasUpdate) FilledButton.icon( onPressed: () => _updateExtension(ext), - icon: const Icon(Icons.update), + icon: Icon( + context.isMornye + ? CupertinoIcons.arrow_clockwise + : Icons.update, + ), label: Text( '${context.l10n.storeUpdate} v${ext.version}', ), @@ -249,7 +259,11 @@ class _ExtensionDetailsScreenState Expanded( child: OutlinedButton.icon( onPressed: null, - icon: const Icon(Icons.check), + icon: Icon( + context.isMornye + ? CupertinoIcons.check_mark + : Icons.check, + ), label: Text(context.l10n.storeInstalled), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 52), @@ -262,7 +276,11 @@ class _ExtensionDetailsScreenState const SizedBox(width: 12), IconButton.filled( onPressed: () => _uninstallExtension(ext), - icon: const Icon(Icons.delete_outline), + icon: Icon( + context.isMornye + ? CupertinoIcons.trash + : Icons.delete_outline, + ), style: IconButton.styleFrom( backgroundColor: colorScheme.errorContainer, foregroundColor: colorScheme.onErrorContainer, @@ -278,7 +296,11 @@ class _ExtensionDetailsScreenState else FilledButton.icon( onPressed: () => _installExtension(ext), - icon: const Icon(Icons.download), + icon: Icon( + context.isMornye + ? CupertinoIcons.arrow_down_circle + : Icons.download, + ), label: Text(context.l10n.storeInstall), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), @@ -309,7 +331,11 @@ class _ExtensionDetailsScreenState EdgeInsets.symmetric(horizontal: wideListInset(context)), child: Row( children: [ - Icon(icon, size: 20, color: colorScheme.primary), + Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + size: 20, + color: colorScheme.primary, + ), const SizedBox(width: 8), Text( title, @@ -360,17 +386,30 @@ class _ExtensionDetailsScreenState runSpacing: 8, children: ext.tags .map( - (tag) => Chip( - label: Text(tag), - backgroundColor: colorScheme.surfaceContainer, - labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), + (tag) => context.isMornye + ? ExtensionRepoCard( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + tag, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ) + : Chip( + label: Text(tag), + backgroundColor: colorScheme.surfaceContainer, + labelStyle: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), ) .toList(), ), @@ -388,12 +427,9 @@ class _ExtensionDetailsScreenState const EdgeInsets.symmetric(horizontal: 16, vertical: 8) + EdgeInsets.symmetric(horizontal: wideListInset(context)), sliver: SliverToBoxAdapter( - child: Card( - elevation: 0, + child: ExtensionRepoCard( color: colorScheme.surfaceContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + radius: 16, child: Column( children: [ _MetadataRow( @@ -437,12 +473,9 @@ class _ExtensionDetailsScreenState const EdgeInsets.symmetric(horizontal: 16, vertical: 8) + EdgeInsets.symmetric(horizontal: wideListInset(context)), sliver: SliverToBoxAdapter( - child: Card( - elevation: 0, + child: ExtensionRepoCard( color: colorScheme.surfaceContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + radius: 16, child: Column( children: [ _CapabilityRow( @@ -544,15 +577,14 @@ class _ExtensionDetailsScreenState .installExtension(ext.id, tempDir.path, storage.extensionsDir); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionInstalled(ext.displayName) - : context.l10n.snackbarFailedToInstall, - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionInstalled(ext.displayName) + : context.l10n.snackbarFailedToInstall, ), + behavior: SnackBarBehavior.floating, ); } } @@ -565,38 +597,39 @@ class _ExtensionDetailsScreenState .updateExtension(ext.id, tempDir.path); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionUpdated(ext.displayName) - : context.l10n.snackbarFailedToUpdate, - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionUpdated(ext.displayName) + : context.l10n.snackbarFailedToUpdate, ), + behavior: SnackBarBehavior.floating, ); } } Future _uninstallExtension(RepoExtension ext) async { - final confirm = await showDialog( + final confirm = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogUninstallExtension), content: Text( context.l10n.dialogUninstallExtensionMessage(ext.displayName), ), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.pop(context, true), - child: Text( - context.l10n.dialogUninstall, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, ), + child: Text(context.l10n.dialogUninstall), ), ], ), @@ -735,7 +768,7 @@ class _CapabilityRow extends StatelessWidget { child: Row( children: [ Icon( - icon, + context.isMornye ? mornyeIconFor(icon) : icon, size: 20, color: enabled ? colorScheme.primary : colorScheme.outline, ), @@ -747,7 +780,11 @@ class _CapabilityRow extends StatelessWidget { ), ), Icon( - enabled ? Icons.check_circle : Icons.cancel_outlined, + context.isMornye + ? (enabled + ? CupertinoIcons.check_mark_circled + : CupertinoIcons.clear_circled) + : (enabled ? Icons.check_circle : Icons.cancel_outlined), size: 20, color: enabled ? colorScheme.primary : colorScheme.outline, ), diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 5f97de80..7a1cd932 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons, CupertinoTextField; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/extension_row.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; @@ -12,6 +15,11 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/repo/extension_details_screen.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/extension_repo_card.dart'; class RepoTab extends ConsumerStatefulWidget { const RepoTab({super.key}); @@ -80,7 +88,6 @@ class _RepoTabState extends ConsumerState { ); } final colorScheme = Theme.of(context).colorScheme; - final bottomInset = context.navBarBottomInset; return Scaffold( body: RefreshIndicator( @@ -93,7 +100,9 @@ class _RepoTabState extends ConsumerState { actions: [ if (hasRegistryUrl) IconButton( - icon: const Icon(Icons.link), + icon: Icon( + context.isMornye ? CupertinoIcons.link : Icons.link, + ), tooltip: context.l10n.storeChangeRepoTooltip, onPressed: () => _showChangeRepoDialog(registryUrl), ), @@ -103,7 +112,10 @@ class _RepoTabState extends ConsumerState { if (!hasRegistryUrl) SliverFillRemaining( hasScrollBody: false, - child: _buildSetupRepoState(colorScheme, error), + child: Builder( + builder: (context) => + _buildSetupRepoState(context, colorScheme, error), + ), ) else ...[ SliverToBoxAdapter( @@ -220,34 +232,65 @@ class _RepoTabState extends ConsumerState { ), ), - SliverToBoxAdapter( - child: SettingsGroup( - children: filteredExtensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < filteredExtensions.length - 1, - isDownloading: downloadingId == ext.id, - onInstall: () => _installExtension(ext), - onUpdate: () => _updateExtension(ext), - onTap: () => _showExtensionDetails(ext), - ); - }).toList(), + if (context.isMornye) + SliverToBoxAdapter( + child: ExtensionRepoCard( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: filteredExtensions.asMap().entries.map(( + entry, + ) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), + ), + ), + ) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: filteredExtensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), + ), ), - ), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ], - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ), ), ); } - Widget _buildSetupRepoState(ColorScheme colorScheme, String? error) { + Widget _buildSetupRepoState( + BuildContext context, + ColorScheme colorScheme, + String? error, + ) { // Bottom padding keeps the content optically centered in the area // visible above the translucent nav bar. return Padding( @@ -358,9 +401,9 @@ class _RepoTabState extends ConsumerState { void _showChangeRepoDialog(String currentUrl) { final changeUrlController = TextEditingController(text: currentUrl); - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.storeRepoDialogTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -383,47 +426,57 @@ class _RepoTabState extends ConsumerState { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 16), - TextField( - controller: changeUrlController, - decoration: InputDecoration( - hintText: context.l10n.storeRepoUrlHint, - labelText: context.l10n.storeNewRepoUrlLabel, - prefixIcon: const Icon(Icons.link), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, + if (context.isMornye) + CupertinoTextField( + controller: changeUrlController, + placeholder: context.l10n.storeNewRepoUrlLabel, + keyboardType: TextInputType.url, + autocorrect: false, + padding: const EdgeInsets.all(12), + ) + else + TextField( + controller: changeUrlController, + decoration: InputDecoration( + hintText: context.l10n.storeRepoUrlHint, + labelText: context.l10n.storeNewRepoUrlLabel, + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, ), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2, - ), - ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), + keyboardType: TextInputType.url, + autocorrect: false, ), - keyboardType: TextInputType.url, - autocorrect: false, - ), ], ), actions: [ - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () { Navigator.of(context).pop(); ref.read(repoProvider.notifier).removeRegistryUrl(); @@ -433,11 +486,13 @@ class _RepoTabState extends ConsumerState { ), child: Text(context.l10n.dialogRemove), ), - TextButton( + AppDialogAction( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () { final newUrl = changeUrlController.text.trim(); Navigator.of(context).pop(); @@ -540,15 +595,14 @@ class _RepoTabState extends ConsumerState { .installExtension(ext.id, tempDir.path, storage.extensionsDir); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionInstalledEnable(ext.displayName) - : context.l10n.snackbarFailedToInstallNamed(ext.displayName), - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionInstalledEnable(ext.displayName) + : context.l10n.snackbarFailedToInstallNamed(ext.displayName), ), + behavior: SnackBarBehavior.floating, ); } } @@ -561,24 +615,23 @@ class _RepoTabState extends ConsumerState { .updateExtension(ext.id, tempDir.path); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionUpdatedVersion( - ext.displayName, - ext.version, - ) - : context.l10n.snackbarFailedToUpdateNamed(ext.displayName), - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionUpdatedVersion( + ext.displayName, + ext.version, + ) + : context.l10n.snackbarFailedToUpdateNamed(ext.displayName), ), + behavior: SnackBarBehavior.floating, ); } } } -class _CategoryChip extends StatelessWidget { +class _CategoryChip extends ConsumerWidget { final String label; final IconData icon; final bool isSelected; @@ -592,8 +645,64 @@ class _CategoryChip extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + return Semantics( + button: true, + selected: isSelected, + label: label, + onTap: onTap, + excludeSemantics: true, + child: MornyeGlass.navigation( + radius: 24, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(24), + child: Container( + constraints: const BoxConstraints(minHeight: 44), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.10) + : Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + mornyeIconFor(icon), + size: 18, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? colorScheme.primary + : colorScheme.onSurface, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } return FilterChip( label: Row( mainAxisSize: MainAxisSize.min, @@ -654,7 +763,9 @@ class _ExtensionItem extends StatelessWidget { onTap: onTap, avatar: ExtensionAvatar( imageUrl: extension.iconUrl, - fallbackIcon: _getCategoryIcon(extension.category), + fallbackIcon: context.isMornye + ? mornyeIconFor(_getCategoryIcon(extension.category)) + : _getCategoryIcon(extension.category), background: extension.isInstalled ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, @@ -727,6 +838,7 @@ class _ExtensionItem extends StatelessWidget { ? FilledButton.tonal( onPressed: onUpdate, style: FilledButton.styleFrom( + shape: context.isMornye ? const StadiumBorder() : null, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: Size(0, context.tokens.minTouchTarget), ), @@ -736,13 +848,20 @@ class _ExtensionItem extends StatelessWidget { ? OutlinedButton( onPressed: null, style: OutlinedButton.styleFrom( + shape: context.isMornye ? const StadiumBorder() : null, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: Size(0, context.tokens.minTouchTarget), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check, size: 16, color: colorScheme.outline), + Icon( + context.isMornye + ? CupertinoIcons.check_mark + : Icons.check, + size: 16, + color: colorScheme.outline, + ), SizedBox(width: tokens.gapXs), Text( context.l10n.storeInstalled, @@ -754,6 +873,13 @@ class _ExtensionItem extends StatelessWidget { : FilledButton( onPressed: onInstall, style: FilledButton.styleFrom( + shape: context.isMornye ? const StadiumBorder() : null, + backgroundColor: context.isMornye + ? colorScheme.primary.withValues(alpha: 0.12) + : null, + foregroundColor: context.isMornye + ? colorScheme.primary + : null, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: Size(0, context.tokens.minTouchTarget), ), diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index cfdf821a..7986eb12 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -147,17 +151,19 @@ class AppSettingsPage extends ConsumerWidget { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogDisableHistoryTitle), content: Text(context.l10n.dialogDisableHistoryMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); ref.read(settingsProvider.notifier).setSaveDownloadHistory(false); @@ -166,10 +172,8 @@ class AppSettingsPage extends ConsumerWidget { SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), ); }, - child: Text( - context.l10n.dialogDisableAndClear, - style: TextStyle(color: colorScheme.error), - ), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(context.l10n.dialogDisableAndClear), ), ], ), @@ -181,17 +185,19 @@ class AppSettingsPage extends ConsumerWidget { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogClearHistoryTitle), content: Text(context.l10n.dialogClearHistoryMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); @@ -199,10 +205,8 @@ class AppSettingsPage extends ConsumerWidget { SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), ); }, - child: Text( - context.l10n.dialogClear, - style: TextStyle(color: colorScheme.error), - ), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(context.l10n.dialogClear), ), ], ), @@ -213,15 +217,18 @@ class AppSettingsPage extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - showDialog( + showAppDialog( context: context, barrierDismissible: false, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( content: Row( children: [ - const CircularProgressIndicator(), + if (context.isMornye) + const CupertinoActivityIndicator() + else + const CircularProgressIndicator(), const SizedBox(width: 16), - Text(context.l10n.cleanupOrphanedDownloads), + Expanded(child: Text(context.l10n.cleanupOrphanedDownloads)), ], ), ), @@ -275,8 +282,12 @@ class _UpdateChannelSelector extends StatelessWidget { Row( children: [ Icon( - Icons.new_releases, - color: colorScheme.onSurfaceVariant, + context.isMornye + ? mornyeIconFor(Icons.new_releases) + : Icons.new_releases, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, size: 24, ), const SizedBox(width: 16), @@ -378,8 +389,12 @@ class _VerificationBrowserModeSelector extends StatelessWidget { Row( children: [ Icon( - Icons.open_in_browser, - color: colorScheme.onSurfaceVariant, + context.isMornye + ? mornyeIconFor(Icons.open_in_browser) + : Icons.open_in_browser, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, size: 24, ), const SizedBox(width: 16), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 180f3cf8..07469055 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/constants/language_choices.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -23,6 +28,71 @@ class AppearanceSettingsPage extends ConsumerWidget { slivers: [ AppSliverHeader.page(title: context.l10n.appearanceTitle), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.appearanceStyle), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + for (final style in AppThemeStyle.values) + ListTile( + title: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + style == AppThemeStyle.mornye + ? 'Mornye' + : 'Material', + ), + if (style == AppThemeStyle.mornye) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Beta', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + leading: Icon( + context.isMornye + ? mornyeIconFor( + style == AppThemeStyle.mornye + ? Icons.music_note_outlined + : Icons.palette_outlined, + ) + : style == AppThemeStyle.mornye + ? Icons.music_note_outlined + : Icons.palette_outlined, + ), + trailing: themeSettings.style == style + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + selected: themeSettings.style == style, + onTap: () => + ref.read(themeProvider.notifier).setStyle(style), + ), + ], + ), + ), + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric( @@ -33,37 +103,39 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionColor), - ), - - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.wallpaper, - title: context.l10n.appearanceDynamicColor, - subtitle: context.l10n.appearanceDynamicColorSubtitle, - value: themeSettings.useDynamicColor, - onChanged: (value) => ref - .read(themeProvider.notifier) - .setUseDynamicColor(value), - showDivider: false, - ), - ], - ), - ), - if (!themeSettings.useDynamicColor) + if (themeSettings.style == AppThemeStyle.material) ...[ SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _ColorPalettePicker( - currentColor: themeSettings.seedColorValue, - onColorSelected: (color) => - ref.read(themeProvider.notifier).setSeedColor(color), - ), + child: SettingsSectionHeader(title: context.l10n.sectionColor), + ), + + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.wallpaper, + title: context.l10n.appearanceDynamicColor, + subtitle: context.l10n.appearanceDynamicColorSubtitle, + value: themeSettings.useDynamicColor, + onChanged: (value) => ref + .read(themeProvider.notifier) + .setUseDynamicColor(value), + showDivider: false, + ), + ], ), ), + if (!themeSettings.useDynamicColor) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _ColorPalettePicker( + currentColor: themeSettings.seedColorValue, + onColorSelected: (color) => + ref.read(themeProvider.notifier).setSeedColor(color), + ), + ), + ), + ], SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionTheme), @@ -76,7 +148,8 @@ class AppearanceSettingsPage extends ConsumerWidget { onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), ), - if (Theme.of(context).brightness == Brightness.dark) + if (themeSettings.style == AppThemeStyle.material && + Theme.of(context).brightness == Brightness.dark) SettingsSwitchItem( icon: Icons.brightness_2, title: context.l10n.appearanceAmoledDark, @@ -164,6 +237,63 @@ class _ThemePreviewCard extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; + if (context.isMornye) { + return ExcludeSemantics( + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.navLibrary, + style: Theme.of(context).textTheme.headlineLarge, + ), + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.library_music_outlined, + color: colorScheme.primary, + ), + const SizedBox(width: 16), + Text( + 'SpotiFLAC Mobile', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + const SizedBox(height: 20), + MornyeTabBar( + destinations: [ + NavigationDestination( + icon: const Icon(Icons.home_outlined), + label: context.l10n.navHome, + ), + NavigationDestination( + icon: const Icon(Icons.library_music_outlined), + label: context.l10n.navLibrary, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + label: context.l10n.navSettings, + ), + ], + selectedIndex: 1, + onSelected: (_) {}, + blurEnabled: false, + ), + ], + ), + ), + ), + ); + } + return RepaintBoundary( child: LayoutBuilder( builder: (context, constraints) { @@ -178,7 +308,7 @@ class _ThemePreviewCard extends StatelessWidget { ); return Container( - constraints: BoxConstraints(minHeight: previewHeight), + height: previewHeight, width: double.infinity, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, @@ -452,6 +582,21 @@ class _ThemeModeSelector extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + final labels = { + ThemeMode.system: context.l10n.appearanceThemeSystem, + ThemeMode.light: context.l10n.appearanceThemeLight, + ThemeMode.dark: context.l10n.appearanceThemeDark, + }; + return Padding( + padding: const EdgeInsets.all(12), + child: MornyeSegmentedControl( + labels: labels.values.toList(), + selectedIndex: labels.keys.toList().indexOf(currentMode), + onChanged: (index) => onChanged(labels.keys.elementAt(index)), + ), + ); + } return Padding( padding: const EdgeInsets.all(12), child: Row( @@ -577,7 +722,7 @@ class _LanguageSelector extends StatelessWidget { void _showLanguagePicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, @@ -602,7 +747,7 @@ class _LanguageSelector extends StatelessWidget { itemBuilder: (context, index) { final lang = supportedLanguageChoices[index]; final isSelected = currentLocale == lang.$1; - return ListTile( + return AppSheetOption( leading: Icon( lang.$3, color: isSelected diff --git a/lib/screens/settings/backup_restore_page.dart b/lib/screens/settings/backup_restore_page.dart index 73de81a2..a1b90827 100644 --- a/lib/screens/settings/backup_restore_page.dart +++ b/lib/screens/settings/backup_restore_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -176,11 +177,11 @@ class _BackupRestorePageState extends ConsumerState { Future _confirmRestore(BackupBundle bundle) { final l10n = context.l10n; - return showDialog( + return showAppDialog( context: context, builder: (dialogContext) { final theme = Theme.of(dialogContext); - return AlertDialog( + return AppAlertDialog( title: Text(l10n.backupRestoreConfirmTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -233,11 +234,14 @@ class _BackupRestorePageState extends ConsumerState { ], ), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.backupRestoreConfirmButton), ), diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index a4766361..8b5ce546 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -2,6 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -238,17 +241,20 @@ class _CacheManagementPageState extends ConsumerState { } Future _confirmClear(String target) async { - final confirm = await showDialog( + final confirm = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.cacheClearConfirmTitle), content: Text(context.l10n.cacheClearConfirmMessage(target)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.dialogClear), ), @@ -259,17 +265,20 @@ class _CacheManagementPageState extends ConsumerState { } Future _confirmClearAll() async { - final confirm = await showDialog( + final confirm = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.cacheClearAllConfirmTitle), content: Text(context.l10n.cacheClearAllConfirmMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.dialogClear), ), @@ -381,9 +390,28 @@ class _CacheManagementPageState extends ConsumerState { ); } + Widget _overviewSurface({required Widget child}) { + final scheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final content = Padding(padding: const EdgeInsets.all(16), child: child); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: DecoratedBox( + decoration: BoxDecoration( + color: mornye + ? scheme.surfaceContainer + : scheme.primaryContainer.withValues(alpha: 0.28), + borderRadius: BorderRadius.circular(mornye ? 28 : 18), + ), + child: content, + ), + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; final overview = _overview; return Scaffold( @@ -406,13 +434,7 @@ class _CacheManagementPageState extends ConsumerState { ) else ...[ SliverToBoxAdapter( - child: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 4), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.28), - borderRadius: BorderRadius.circular(18), - ), + child: _overviewSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -420,7 +442,9 @@ class _CacheManagementPageState extends ConsumerState { context.l10n.cacheSummaryTitle, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, - color: colorScheme.onPrimaryContainer, + color: mornye + ? colorScheme.onSurface + : colorScheme.onPrimaryContainer, ), ), const SizedBox(height: 6), @@ -429,16 +453,20 @@ class _CacheManagementPageState extends ConsumerState { formatBytes(overview.totalKnownDiskCacheBytes), ), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onPrimaryContainer, + color: mornye + ? colorScheme.onSurface + : colorScheme.onPrimaryContainer, ), ), const SizedBox(height: 2), Text( context.l10n.cacheSummarySubtitle, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onPrimaryContainer.withValues( - alpha: 0.85, - ), + color: mornye + ? colorScheme.onSurfaceVariant + : colorScheme.onPrimaryContainer.withValues( + alpha: 0.85, + ), ), ), const SizedBox(height: 12), @@ -446,7 +474,12 @@ class _CacheManagementPageState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - FilledButton.tonalIcon( + AppActionButton( + isDestructive: true, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.secondaryContainer, + foregroundColor: colorScheme.onSecondaryContainer, + ), onPressed: _isBusy ? null : () async { @@ -465,7 +498,9 @@ class _CacheManagementPageState extends ConsumerState { icon: const Icon(Icons.delete_sweep_outlined), label: Text(context.l10n.cacheClearAll), ), - OutlinedButton.icon( + AppActionButton( + outlined: true, + glass: false, onPressed: _isBusy ? null : _refreshOverview, icon: const Icon(Icons.refresh), label: Text(context.l10n.cacheRefreshStats), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 042453cf..3e384e77 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { const DownloadSettingsPage({super.key}); @@ -403,7 +404,7 @@ class _DownloadSettingsPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ for (final format in autoConvertFormats) - ListTile( + AppSheetOption( leading: Icon( format == 'opus' ? Icons.graphic_eq @@ -451,7 +452,7 @@ class _DownloadSettingsPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ for (final bitrate in autoConvertBitrates) - ListTile( + AppSheetOption( leading: const Icon(Icons.speed_outlined), title: Text(bitrate.replaceAll('k', ' kbps')), trailing: normalizedCurrent == bitrate @@ -479,7 +480,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -506,7 +507,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ), - ListTile( + AppSheetOption( leading: const Icon(Icons.signal_cellular_alt), title: Text(context.l10n.settingsDownloadNetworkAny), subtitle: Text(context.l10n.downloadNetworkAnySubtitle), @@ -520,7 +521,7 @@ class _DownloadSettingsPageState extends ConsumerState { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.wifi), title: Text(context.l10n.settingsDownloadNetworkWifiOnly), subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle), @@ -547,7 +548,7 @@ class _DownloadSettingsPageState extends ConsumerState { int current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -575,7 +576,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), for (final count in const [1, 2, 3]) - ListTile( + AppSheetOption( leading: Icon( count == 1 ? Icons.looks_one_outlined @@ -797,7 +798,7 @@ class _DownloadSettingsPageState extends ConsumerState { ]; final colorScheme = Theme.of(context).colorScheme; final normalizedCurrent = current.trim().toUpperCase(); - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -883,25 +884,12 @@ class _RegionPickerSheetState extends State<_RegionPickerSheet> { ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: TextField( + child: AppSearchField( controller: _searchController, - autofocus: false, - textInputAction: TextInputAction.search, onChanged: (value) => setState(() => _query = value), - decoration: InputDecoration( - hintText: MaterialLocalizations.of(context).searchFieldLabel, - prefixIcon: const Icon(Icons.search), - suffixIcon: _query.isEmpty - ? null - : IconButton( - tooltip: context.l10n.dialogClear, - icon: const Icon(Icons.close), - onPressed: () { - _searchController.clear(); - setState(() => _query = ''); - }, - ), - ), + hintText: MaterialLocalizations.of(context).searchFieldLabel, + clearTooltip: context.l10n.dialogClear, + onClear: () => setState(() => _query = ''), ), ), Expanded( @@ -920,7 +908,7 @@ class _RegionPickerSheetState extends State<_RegionPickerSheet> { final code = matches[index]; final isSelected = code == widget.selected; final countryName = widget.countryNameOf(context, code); - return ListTile( + return AppSheetOption( title: Text(countryName != code ? countryName : code), subtitle: countryName != code ? Text(code) : null, trailing: isSelected diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 7d081e23..73cad455 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -1,6 +1,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons, CupertinoTextField; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -94,14 +102,7 @@ class _ExtensionDetailPageState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - borderRadius: BorderRadius.circular(20), - ), + child: _ExtensionOverviewSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -111,7 +112,9 @@ class _ExtensionDetailPageState extends ConsumerState { width: 56, height: 56, decoration: BoxDecoration( - color: hasError + color: context.isMornye + ? MornyeTheme.controlFill(context) + : hasError ? colorScheme.errorContainer : colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16), @@ -128,7 +131,13 @@ class _ExtensionDetailPageState extends ConsumerState { fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Icon( - hasError + context.isMornye + ? (hasError + ? CupertinoIcons + .exclamationmark_circle + : CupertinoIcons + .square_grid_2x2) + : hasError ? Icons.error_outline : Icons.extension, size: 28, @@ -140,7 +149,12 @@ class _ExtensionDetailPageState extends ConsumerState { ), ) : Icon( - hasError + context.isMornye + ? (hasError + ? CupertinoIcons + .exclamationmark_circle + : CupertinoIcons.square_grid_2x2) + : hasError ? Icons.error_outline : Icons.extension, size: 28, @@ -169,8 +183,9 @@ class _ExtensionDetailPageState extends ConsumerState { ], ), ), - Switch( + AppSwitch( value: extension.enabled, + semanticLabel: extension.displayName, onChanged: hasError ? null : (enabled) => ref @@ -191,16 +206,21 @@ class _ExtensionDetailPageState extends ConsumerState { ), ], const SizedBox(height: 16), + if (context.isMornye) + Divider( + color: MornyeTheme.metadataDividerColor(context), + ), _InfoRow( label: context.l10n.extensionId, value: extension.id, ), - _InfoRow( - label: context.l10n.extensionsVersion( - extension.version, + if (!context.isMornye) + _InfoRow( + label: context.l10n.extensionsVersion( + extension.version, + ), + value: '', ), - value: '', - ), if (hasError && extension.errorMessage != null) _InfoRow( label: context.l10n.extensionError, @@ -232,6 +252,7 @@ class _ExtensionDetailPageState extends ConsumerState { else SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.settings.asMap().entries.map((entry) { final index = entry.key; final setting = entry.value; @@ -257,6 +278,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: [ _HealthSummaryItem( status: healthStatus, @@ -286,6 +308,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: [ _URLHandlerInfo(patterns: extension.urlHandler!.patterns), ], @@ -302,6 +325,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.qualityOptions.asMap().entries.map(( entry, ) { @@ -325,6 +349,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.postProcessing!.hooks.asMap().entries.map( (entry) { final index = entry.key; @@ -348,6 +373,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.permissions.asMap().entries.map((entry) { final index = entry.key; final permission = entry.value; @@ -367,6 +393,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: [ _CapabilityItem( icon: Icons.search, @@ -437,10 +464,10 @@ class _ExtensionDetailPageState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: OutlinedButton.icon( + child: AppDialogAction( onPressed: () => _confirmRemove(context), - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.extensionRemoveButton), + isDestructive: true, + outlined: true, style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error), @@ -449,6 +476,18 @@ class _ExtensionDetailPageState extends ConsumerState { borderRadius: BorderRadius.circular(16), ), ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + context.isMornye + ? CupertinoIcons.trash + : Icons.delete_outline, + ), + const SizedBox(width: 8), + Flexible(child: Text(context.l10n.extensionRemoveButton)), + ], + ), ), ), ), @@ -492,17 +531,20 @@ class _ExtensionDetailPageState extends ConsumerState { Future _confirmRemove(BuildContext context) async { final colorScheme = Theme.of(context).colorScheme; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogRemoveExtension), content: Text(context.l10n.dialogRemoveExtensionMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogRemove), @@ -523,6 +565,44 @@ class _ExtensionDetailPageState extends ConsumerState { } } +class _ExtensionOverviewSurface extends StatelessWidget { + const _ExtensionOverviewSurface({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final content = Padding(padding: const EdgeInsets.all(20), child: child); + if (context.isMornye) return MornyeGlassPanel(child: content); + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), + ), + child: content, + ); + } +} + +class _ExtensionDivider extends StatelessWidget { + const _ExtensionDivider({this.indent = 56}); + + final double indent; + + @override + Widget build(BuildContext context) => Divider( + height: 1, + thickness: context.isMornye ? 0.5 : 1, + indent: indent, + endIndent: 16, + color: context.isMornye + ? MornyeTheme.metadataDividerColor(context) + : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3), + ); +} + /// Long OAuth URLs: selectable text so users can copy without relying on snackbars. class _OauthLoginLinkPreview extends StatelessWidget { final String? value; @@ -626,8 +706,10 @@ class _CapabilityItem extends StatelessWidget { child: Row( children: [ Icon( - icon, - color: enabled ? colorScheme.primary : colorScheme.outline, + context.isMornye ? mornyeIconFor(icon) : icon, + color: context.isMornye || enabled + ? colorScheme.primary + : colorScheme.outline, ), const SizedBox(width: 16), Expanded( @@ -648,20 +730,19 @@ class _CapabilityItem extends StatelessWidget { ), ), Icon( - enabled ? Icons.check_circle : Icons.cancel_outlined, - color: enabled ? colorScheme.primary : colorScheme.outline, + context.isMornye + ? (enabled + ? CupertinoIcons.check_mark_circled_solid + : CupertinoIcons.xmark_circle) + : (enabled ? Icons.check_circle : Icons.cancel_outlined), + color: context.isMornye || enabled + ? colorScheme.primary + : colorScheme.outline, ), ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(), ], ); } @@ -680,7 +761,15 @@ Color _healthStatusColor(ColorScheme colorScheme, String status) { } } -IconData _healthStatusIcon(String status) { +IconData _healthStatusIcon(BuildContext context, String status) { + if (context.isMornye) { + return switch (status) { + 'online' => CupertinoIcons.check_mark_circled_solid, + 'degraded' => CupertinoIcons.exclamationmark_triangle, + 'offline' => CupertinoIcons.exclamationmark_circle, + _ => CupertinoIcons.question_circle, + }; + } switch (status) { case 'online': return Icons.check_circle; @@ -732,7 +821,7 @@ class _HealthSummaryItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - Icon(_healthStatusIcon(statusValue), color: color), + Icon(_healthStatusIcon(context, statusValue), color: color), const SizedBox(width: 16), Expanded( child: Column( @@ -773,18 +862,16 @@ class _HealthSummaryItem extends StatelessWidget { color: colorScheme.primary, ), ) - : const Icon(Icons.refresh), + : Icon( + context.isMornye + ? CupertinoIcons.refresh + : Icons.refresh, + ), ), ], ), ), - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + const _ExtensionDivider(), ], ); } @@ -822,7 +909,7 @@ class _HealthCheckItem extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(_healthStatusIcon(check.status), color: color), + Icon(_healthStatusIcon(context, check.status), color: color), const SizedBox(width: 16), Expanded( child: Column( @@ -858,14 +945,7 @@ class _HealthCheckItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(), ], ); } @@ -899,7 +979,12 @@ class _PermissionItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - Icon(icon, color: colorScheme.onSurfaceVariant), + Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), const SizedBox(width: 16), Expanded( child: Text( @@ -910,14 +995,7 @@ class _PermissionItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(), ], ); } @@ -954,20 +1032,49 @@ class _SettingItemState extends State<_SettingItem> { Widget trailing; switch (widget.setting.type) { case 'boolean': - trailing = Switch( + trailing = AppSwitch( value: widget.value as bool? ?? false, + semanticLabel: widget.setting.label, onChanged: widget.onChanged, ); break; case 'select': - trailing = DropdownButton( - value: widget.value as String?, - items: widget.setting.options?.map((opt) { - return DropdownMenuItem(value: opt, child: Text(opt)); - }).toList(), - onChanged: widget.onChanged, - underline: const SizedBox(), - ); + trailing = context.isMornye + ? ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.42, + ), + child: TextButton( + onPressed: widget.setting.options?.isNotEmpty == true + ? _showOptions + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.value as String? ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + const Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 16, + ), + ], + ), + ), + ) + : DropdownButton( + value: widget.value as String?, + items: widget.setting.options?.map((opt) { + return DropdownMenuItem(value: opt, child: Text(opt)); + }).toList(), + onChanged: widget.onChanged, + underline: const SizedBox(), + ); break; case 'button': trailing = _isLoading @@ -983,7 +1090,7 @@ class _SettingItemState extends State<_SettingItem> { break; default: trailing = Icon( - Icons.chevron_right, + context.isMornye ? CupertinoIcons.chevron_right : Icons.chevron_right, color: colorScheme.onSurfaceVariant, ); } @@ -1015,14 +1122,7 @@ class _SettingItemState extends State<_SettingItem> { ], ), ), - if (widget.showDivider) - Divider( - height: 1, - thickness: 1, - indent: 16, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (widget.showDivider) const _ExtensionDivider(indent: 16), ], ); } @@ -1079,22 +1179,40 @@ class _SettingItemState extends State<_SettingItem> { ), ), ), - if (widget.showDivider) - Divider( - height: 1, - thickness: 1, - indent: 16, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (widget.showDivider) const _ExtensionDivider(indent: 16), ], ); } + Future _showOptions() async { + final value = await showAppBottomSheet( + context: context, + title: widget.setting.label, + maxHeightFactor: 0.7, + builder: (sheetContext) => SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in widget.setting.options ?? []) + AppSheetOption( + title: Text(option), + trailing: option == widget.value + ? const Icon(CupertinoIcons.check_mark) + : null, + onTap: () => Navigator.pop(sheetContext, option), + ), + ], + ), + ), + ); + if (mounted && value != null) widget.onChanged(value); + } + Future _invokeAction(BuildContext context) async { if (widget.setting.action == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarNoActionDefined)), + showAppSnackBar( + context, + content: Text(context.l10n.snackbarNoActionDefined), ); return; } @@ -1121,13 +1239,12 @@ class _SettingItemState extends State<_SettingItem> { payload['error'] as String? ?? result['error'] as String? ?? context.l10n.extensionActionFailed; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.friendlyError( - error, - fallback: context.l10n.extensionActionFailed, - ), + showAppSnackBar( + context, + content: Text( + context.friendlyError( + error, + fallback: context.l10n.extensionActionFailed, ), ), ); @@ -1143,29 +1260,25 @@ class _SettingItemState extends State<_SettingItem> { browserMode: 'external_first', ); if (!launched && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarError('Could not open browser'), - ), + showAppSnackBar( + context, + content: Text( + context.l10n.snackbarError('Could not open browser'), ), ); } } final message = payload['message'] as String?; if (message != null && message.isNotEmpty && context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + showAppSnackBar(context, content: Text(message)); } } } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarError(context.friendlyError(e))), - ), + showAppSnackBar( + context, + content: Text(context.l10n.snackbarError(context.friendlyError(e))), ); } } finally { @@ -1181,34 +1294,51 @@ class _SettingItemState extends State<_SettingItem> { ); final colorScheme = Theme.of(context).colorScheme; - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(widget.setting.label), - content: TextField( - controller: controller, - keyboardType: widget.setting.type == 'number' - ? TextInputType.number - : TextInputType.text, - decoration: InputDecoration( - hintText: - widget.setting.description ?? context.l10n.extensionEnterValue, - filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), + content: context.isMornye + ? Padding( + padding: const EdgeInsets.only(top: 12), + child: CupertinoTextField( + controller: controller, + keyboardType: widget.setting.type == 'number' + ? TextInputType.number + : TextInputType.text, + placeholder: + widget.setting.description ?? + context.l10n.extensionEnterValue, + padding: const EdgeInsets.all(12), + ), + ) + : TextField( + controller: controller, + keyboardType: widget.setting.type == 'number' + ? TextInputType.number + : TextInputType.text, + decoration: InputDecoration( + hintText: + widget.setting.description ?? + context.l10n.extensionEnterValue, + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () { final newValue = widget.setting.type == 'number' ? num.tryParse(controller.text) @@ -1220,7 +1350,7 @@ class _SettingItemState extends State<_SettingItem> { ), ], ), - ); + ).then((_) => controller.dispose()); } } @@ -1245,12 +1375,18 @@ class _PostProcessingHookItem extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: context.isMornye + ? Colors.transparent + : colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(10), ), child: Icon( - Icons.auto_fix_high, - color: colorScheme.onTertiaryContainer, + context.isMornye + ? CupertinoIcons.wand_stars + : Icons.auto_fix_high, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onTertiaryContainer, size: 20, ), ), @@ -1322,14 +1458,7 @@ class _PostProcessingHookItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 72, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(indent: 72), ], ); } @@ -1355,12 +1484,16 @@ class _URLHandlerInfo extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: context.isMornye + ? Colors.transparent + : colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon( - Icons.link, - color: colorScheme.onTertiaryContainer, + context.isMornye ? CupertinoIcons.link : Icons.link, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onTertiaryContainer, size: 24, ), ), @@ -1467,12 +1600,18 @@ class _QualityOptionItem extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: colorScheme.secondaryContainer, + color: context.isMornye + ? Colors.transparent + : colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(10), ), child: Icon( - Icons.high_quality, - color: colorScheme.onSecondaryContainer, + context.isMornye + ? CupertinoIcons.waveform + : Icons.high_quality, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSecondaryContainer, size: 20, ), ), @@ -1497,14 +1636,16 @@ class _QualityOptionItem extends StatelessWidget { ), ), ], - const SizedBox(height: 4), - Text( - quality.id, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.primary, - fontFamily: 'monospace', + if (!context.isMornye) ...[ + const SizedBox(height: 4), + Text( + quality.id, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + ), ), - ), + ], ], ), ), @@ -1530,14 +1671,7 @@ class _QualityOptionItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 72, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(indent: 72), ], ); } diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 1f6f0c7d..3a20397b 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -1,5 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/extension_row.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; @@ -14,6 +16,9 @@ import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -51,6 +56,17 @@ class _ExtensionsPageState extends ConsumerState { Widget build(BuildContext context) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; + final installButton = AppActionButton( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: Text(context.l10n.extensionsInstallButton), + outlined: context.isMornye, + glass: false, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ); return PopScope( canPop: true, @@ -186,23 +202,19 @@ class _ExtensionsPageState extends ConsumerState { label: context.l10n.extensionsInstallButton, child: Padding( padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: _installExtension, - icon: const Icon(Icons.add), - label: Text(context.l10n.extensionsInstallButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), + child: context.isMornye + ? MornyeGlassPanel.overlay( + radius: 28, + child: installButton, + ) + : installButton, ), ), ), SliverToBoxAdapter( child: SettingsInfoCard( + glass: true, icon: Icons.info_outline, tone: SettingsInfoTone.warning, message: context.l10n.extensionsInfoTip, @@ -226,8 +238,9 @@ class _ExtensionsPageState extends ConsumerState { }); if (hasInvalidFile) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarSelectExtFile)), + showAppSnackBar( + context, + content: Text(context.l10n.snackbarSelectExtFile), ); } return; @@ -265,9 +278,7 @@ class _ExtensionsPageState extends ConsumerState { final message = _getInstallResultMessage(installResult); ref.read(extensionProvider.notifier).clearError(); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + showAppSnackBar(context, content: Text(message)); } } finally { try { @@ -366,8 +377,9 @@ class _ExtensionItem extends StatelessWidget { : serviceHealthColor ?? colorScheme.onSurfaceVariant, ), ), - trailing: Switch( + trailing: AppSwitch( value: extension.enabled, + semanticLabel: extension.displayName, onChanged: hasError ? null : onToggle, ), ); @@ -412,6 +424,27 @@ class _DownloadPriorityItem extends ConsumerWidget { (e) => e.enabled && e.hasDownloadProvider, ); + if (context.isMornye) { + return Opacity( + opacity: hasDownloadExtensions ? 1 : 0.5, + child: SettingsItem( + icon: Icons.download, + title: context.l10n.extensionsDownloadPriority, + subtitle: hasDownloadExtensions + ? context.l10n.extensionsDownloadPrioritySubtitle + : context.l10n.extensionsNoDownloadProvider, + onTap: hasDownloadExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ProviderPriorityPage(), + ), + ) + : null, + ), + ); + } + final content = InkWell( onTap: hasDownloadExtensions ? () => Navigator.push( @@ -483,6 +516,27 @@ class _MetadataPriorityItem extends ConsumerWidget { (e) => e.enabled && e.hasMetadataProvider, ); + if (context.isMornye) { + return Opacity( + opacity: hasMetadataExtensions ? 1 : 0.5, + child: SettingsItem( + icon: Icons.search, + title: context.l10n.extensionsMetadataPriority, + subtitle: hasMetadataExtensions + ? context.l10n.extensionsMetadataPrioritySubtitle + : context.l10n.extensionsNoMetadataProvider, + onTap: hasMetadataExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MetadataProviderPriorityPage(), + ), + ) + : null, + ), + ); + } + final content = InkWell( onTap: hasMetadataExtensions ? () => Navigator.push( @@ -554,6 +608,27 @@ class _DownloadFallbackItem extends ConsumerWidget { (e) => e.enabled && e.hasDownloadProvider, ); + if (context.isMornye) { + return Opacity( + opacity: hasDownloadExtensions ? 1 : 0.5, + child: SettingsItem( + icon: Icons.alt_route, + title: context.l10n.extensionsFallbackTitle, + subtitle: hasDownloadExtensions + ? context.l10n.extensionsFallbackSubtitle + : context.l10n.extensionsNoDownloadProvider, + onTap: hasDownloadExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const DownloadFallbackExtensionsPage(), + ), + ) + : null, + ), + ); + } + final content = InkWell( onTap: hasDownloadExtensions ? () => Navigator.push( @@ -640,6 +715,27 @@ class _SearchProviderSelector extends ConsumerWidget { currentProviderName = ext?.displayName ?? resolvedProviderId; } + if (context.isMornye) { + return Opacity( + opacity: hasAnyProvider ? 1 : 0.5, + child: SettingsItem( + icon: Icons.manage_search, + title: context.l10n.extensionsSearchProvider, + subtitle: hasAnyProvider + ? currentProviderName + : context.l10n.extensionsNoCustomSearch, + onTap: hasAnyProvider + ? () => _showSearchProviderPicker( + context, + ref, + settings, + searchProviders, + ) + : null, + ), + ); + } + final content = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -795,6 +891,23 @@ class _HomeFeedProviderSelector extends ConsumerWidget { currentProviderName = ext?.displayName ?? settings.homeFeedProvider!; } + if (context.isMornye) { + return SettingsItem( + icon: Icons.explore_outlined, + title: context.l10n.extensionsHomeFeedProvider, + subtitle: !hasAnyProvider && !homeFeedDisabled + ? context.l10n.extensionsNoHomeFeedExtensions + : currentProviderName, + showDivider: false, + onTap: () => _showHomeFeedProviderPicker( + context, + ref, + settings, + homeFeedProviders, + ), + ); + } + final content = Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index aee04866..5a570de3 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -1,6 +1,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -82,17 +86,19 @@ class _FilesSettingsPageState extends ConsumerState { if (mounted) setState(() => _hasAllFilesAccess = true); } else if (status.isPermanentlyDenied) { if (mounted) { - final shouldOpen = await showDialog( + final shouldOpen = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupStorageAccessRequired), content: Text(context.l10n.allFilesAccessDeniedMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupOpenSettings), ), @@ -486,7 +492,7 @@ class _FilesSettingsPageState extends ConsumerState { final settings = ref.read(settingsProvider); final isSafMode = settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -513,7 +519,7 @@ class _FilesSettingsPageState extends ConsumerState { ), ), ), - ListTile( + AppSheetOption( leading: Icon(Icons.folder_special, color: colorScheme.primary), title: Text(context.l10n.storageAutomaticFolder), subtitle: Text(context.l10n.storageAutomaticDownloadFolder), @@ -527,7 +533,7 @@ class _FilesSettingsPageState extends ConsumerState { notifier.setDownloadTreeUri(''); }, ), - ListTile( + AppSheetOption( leading: Icon(Icons.folder_open, color: colorScheme.primary), title: Text(context.l10n.storageSafRecommended), subtitle: Text(context.l10n.storageDownloadFolderHint), @@ -547,7 +553,7 @@ class _FilesSettingsPageState extends ConsumerState { void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -574,7 +580,7 @@ class _FilesSettingsPageState extends ConsumerState { ), ), ), - ListTile( + AppSheetOption( leading: Icon(Icons.folder_special, color: colorScheme.primary), title: Text(context.l10n.setupAppDocumentsFolder), subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle), @@ -587,7 +593,7 @@ class _FilesSettingsPageState extends ConsumerState { if (ctx.mounted) Navigator.pop(ctx); }, ), - ListTile( + AppSheetOption( leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), title: Text(context.l10n.setupChooseFromFiles), subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), @@ -665,7 +671,7 @@ class _FilesSettingsPageState extends ConsumerState { final save = onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat; - showModalBottomSheet( + showAppModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, @@ -685,7 +691,7 @@ class _FilesSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -755,7 +761,7 @@ class _FilesSettingsPageState extends ConsumerState { Icons.person_outline_outlined, ), ]) - ListTile( + AppSheetOption( leading: Icon(option.$4), title: Text(option.$2), subtitle: Text(option.$3), @@ -782,7 +788,7 @@ class _FilesSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -883,7 +889,7 @@ class _FolderOption extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return ListTile( + return AppSheetOption( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), title: Text(title), subtitle: Column( @@ -997,6 +1003,13 @@ class _FilenameFormatEditorSheetState } Widget _tagChip(ColorScheme colorScheme, String tag) { + if (context.isMornye) { + return AppChoiceChip( + label: Text(tag), + selected: false, + onSelected: (_) => _insertTag(tag), + ); + } return ActionChip( label: Text(tag), onPressed: () => _insertTag(tag), @@ -1082,7 +1095,7 @@ class _FilenameFormatEditorSheetState .toList(), ), const SizedBox(height: 12), - SwitchListTile( + AppSwitchListTile( value: _showAdvancedTags, onChanged: (value) => setState(() => _showAdvancedTags = value), @@ -1106,7 +1119,7 @@ class _FilenameFormatEditorSheetState Row( children: [ Expanded( - child: TextButton( + child: AppDialogAction( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -1120,7 +1133,8 @@ class _FilenameFormatEditorSheetState const SizedBox(width: 12), Expanded( flex: 2, - child: FilledButton( + child: AppDialogAction( + filled: true, onPressed: () { widget.onSave(_controller.text); Navigator.pop(context); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 0f653811..33ca9d34 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -1,5 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -86,17 +88,19 @@ class _LibrarySettingsPageState extends ConsumerState { return true; } else if (status.isPermanentlyDenied) { if (mounted) { - final shouldOpen = await showDialog( + final shouldOpen = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.libraryStorageAccessRequired), content: Text(context.l10n.libraryStorageAccessMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupOpenSettings), ), @@ -201,17 +205,20 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _clearLibrary() async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.libraryClearConfirmTitle), content: Text(context.l10n.libraryClearConfirmMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, @@ -246,17 +253,20 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _removeSource(LocalLibrarySource source) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.libraryRemoveFolder), content: Text(context.l10n.libraryRemoveFolderMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, @@ -291,7 +301,7 @@ class _LibrarySettingsPageState extends ConsumerState { void _showAutoScanPicker(BuildContext context, String current) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -397,7 +407,7 @@ class _LibrarySettingsPageState extends ConsumerState { ('singles', Icons.music_note, context.l10n.historyFilterSingles), ('playlists', Icons.queue_music, context.l10n.searchPlaylists), ]; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -484,7 +494,7 @@ class _LibrarySettingsPageState extends ConsumerState { context.l10n.libraryQualityLabelFileFormat, ), ]; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1008,21 +1018,21 @@ class _LibrarySourceSettingsItem extends StatelessWidget { builder: (context) => Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( + AppSheetOption( enabled: canScan, leading: const Icon(Icons.refresh_rounded), title: Text(context.l10n.libraryScan), subtitle: Text(context.l10n.libraryScanSubtitle), onTap: canScan ? () => Navigator.pop(context, 'scan') : null, ), - ListTile( + AppSheetOption( enabled: canScan, leading: const Icon(Icons.sync_rounded), title: Text(context.l10n.libraryForceFullScan), subtitle: Text(context.l10n.libraryForceFullScanSubtitle), onTap: canScan ? () => Navigator.pop(context, 'full_scan') : null, ), - ListTile( + AppSheetOption( leading: Icon( Icons.delete_outline, color: Theme.of(context).colorScheme.error, @@ -1099,7 +1109,9 @@ class _LibrarySourceSettingsItem extends StatelessWidget { determinate: totalFiles > 0 && !isFinalizing, ), ), - Switch.adaptive( + AppSwitch( + adaptive: true, + semanticLabel: title, value: source.enabled, onChanged: enabled ? onEnabledChanged : null, ), @@ -1531,7 +1543,7 @@ class _AutoScanOption extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( + return AppSheetOption( leading: Icon(icon), title: Text(title), trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null, diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index d5471ef9..1f9241dc 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; final RegExp _domainPattern = RegExp( @@ -197,17 +199,20 @@ class _LogScreenState extends State { } void _clearLogs() { - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.logClearLogsTitle), content: Text(context.l10n.logClearLogsMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () { _selectionMode = false; _selectedEntries.clear(); @@ -426,43 +431,13 @@ class _LogScreenState extends State { horizontal: 20, vertical: 12, ), - child: Row( - children: [ - Icon(Icons.search, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 16), - Expanded( - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.logSearchHint, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - tooltip: context.l10n.logClearSearch, - icon: const Icon(Icons.clear, size: 20), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - ), - onChanged: (value) { - setState(() => _searchQuery = value); - }, - ), - ), - ], + child: AppSearchField( + controller: _searchController, + hintText: context.l10n.logSearchHint, + clearTooltip: context.l10n.logClearSearch, + onClear: () => setState(() => _searchQuery = ''), + onChanged: (value) => + setState(() => _searchQuery = value), ), ), ], diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index e13a1238..41860362 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -114,14 +115,10 @@ class _LyricsProviderPriorityPageState iconColor: Theme.of(context).colorScheme.primary, name: info.name, subtitle: info.description, - trailing: SizedBox( - height: 32, - child: FittedBox( - child: Switch( - value: true, - onChanged: (_) => _disableProvider(id), - ), - ), + trailing: AppSwitch( + value: true, + semanticLabel: info.name, + onChanged: (_) => _disableProvider(id), ), ); }, @@ -346,11 +343,10 @@ class _DisabledProviderItem extends StatelessWidget { ], ), ), - SizedBox( - height: 32, - child: FittedBox( - child: Switch(value: false, onChanged: (_) => onToggle()), - ), + AppSwitch( + value: false, + semanticLabel: info.name, + onChanged: (_) => onToggle(), ), ], ), diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 0fafb17d..01773d9a 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -6,6 +6,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class LyricsSettingsPage extends ConsumerWidget { const LyricsSettingsPage({super.key}); @@ -214,7 +217,7 @@ class LyricsSettingsPage extends ConsumerWidget { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -241,7 +244,7 @@ class LyricsSettingsPage extends ConsumerWidget { ), ), ), - ListTile( + AppSheetOption( leading: const Icon(Icons.audiotrack), title: Text(context.l10n.lyricsModeEmbed), subtitle: Text(context.l10n.lyricsModeEmbedSubtitle), @@ -251,7 +254,7 @@ class LyricsSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.insert_drive_file_outlined), title: Text(context.l10n.lyricsModeExternal), subtitle: Text(context.l10n.lyricsModeExternalSubtitle), @@ -261,7 +264,7 @@ class LyricsSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.library_music_outlined), title: Text(context.l10n.lyricsModeBoth), subtitle: Text(context.l10n.lyricsModeBothSubtitle), @@ -286,7 +289,7 @@ class LyricsSettingsPage extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final controller = TextEditingController(text: currentLanguage); - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -325,38 +328,55 @@ class LyricsSettingsPage extends ConsumerWidget { ), ), const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogCancel), - ), - const SizedBox(width: 8), - TextButton( - onPressed: () { - ref - .read(settingsProvider.notifier) - .setMusixmatchLanguage(''); - Navigator.pop(context); - }, - child: Text(context.l10n.downloadMusixmatchAuto), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () { - final normalized = controller.text - .trim() - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); - ref - .read(settingsProvider.notifier) - .setMusixmatchLanguage(normalized); - Navigator.pop(context); - }, - child: Text(context.l10n.dialogSave), - ), - ], + Builder( + builder: (context) { + final actions = [ + AppDialogAction( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.dialogCancel), + ), + AppDialogAction( + onPressed: () { + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(''); + Navigator.pop(context); + }, + child: Text(context.l10n.downloadMusixmatchAuto), + ), + AppDialogAction( + filled: true, + onPressed: () { + final normalized = controller.text + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(normalized); + Navigator.pop(context); + }, + child: Text(context.l10n.dialogSave), + ), + ]; + return context.isMornye + ? Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: actions, + ) + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + actions[0], + const SizedBox(width: 8), + actions[1], + const SizedBox(width: 8), + actions[2], + ], + ); + }, ), ], ), diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 04bab912..90cd522f 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; class MetadataSettingsPage extends ConsumerWidget { const MetadataSettingsPage({super.key}); @@ -173,7 +174,7 @@ class MetadataSettingsPage extends ConsumerWidget { ) { const options = [0, 500, 1000, 1500, 2000]; final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -203,7 +204,7 @@ class MetadataSettingsPage extends ConsumerWidget { ), ), for (final maxDimension in options) - ListTile( + AppSheetOption( leading: Icon( maxDimension == 0 ? Icons.image_outlined @@ -236,7 +237,7 @@ class MetadataSettingsPage extends ConsumerWidget { String currentMode, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -263,7 +264,7 @@ class MetadataSettingsPage extends ConsumerWidget { ), ), ), - ListTile( + AppSheetOption( leading: const Icon(Icons.segment_outlined), title: Text(context.l10n.optionsArtistTagModeJoined), subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), @@ -277,7 +278,7 @@ class MetadataSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.library_music_outlined), title: Text(context.l10n.optionsArtistTagModeSplitVorbis), subtitle: Text( diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index c5299359..0efa3863 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/settings/lyrics_settings_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart'; import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -266,7 +267,9 @@ class _SettingsTabState extends ConsumerState { final destination = targetLabel == null ? page : SettingsSearchHighlightScope(targetLabel: targetLabel, child: page); - await Navigator.of(context).push(slidePageRoute(page: destination)); + await Navigator.of( + context, + ).push(slidePageRoute(page: MornyeSettingsTheme(child: destination))); if (!mounted) return; // A route's focus scope remembers its previously focused child. Keep the @@ -284,7 +287,7 @@ class _SettingsTabState extends ConsumerState { return SettingsItem( icon: destination.icon, title: destination.title, - subtitle: destination.subtitle, + subtitle: context.isMornye ? null : destination.subtitle, showDivider: showDivider, onTap: () => _navigateTo(context, destination.pageBuilder()), ); @@ -315,16 +318,15 @@ class _SettingsTabState extends ConsumerState { @override Widget build(BuildContext context) { final tokens = context.tokens; - final bottomInset = context.navBarBottomInset; final wideInset = wideListInset(context); final query = SettingsSearchQuery(_query); final groups = _groups(context); final margin = EdgeInsets.fromLTRB( tokens.gapLg + wideInset, - tokens.gapSm, + context.isMornye ? 12 : tokens.gapSm, tokens.gapLg + wideInset, - tokens.gapSm, + context.isMornye ? 12 : tokens.gapSm, ); final List body; @@ -384,31 +386,33 @@ class _SettingsTabState extends ConsumerState { ]; } - return CustomScrollView( - slivers: [ - AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB( - tokens.gapLg + wideInset, - tokens.gapSm, - tokens.gapLg + wideInset, - tokens.gapSm, - ), - child: AppSearchField( - controller: _searchController, - focusNode: _searchFocusNode, - hintText: context.l10n.settingsSearchHint, - clearTooltip: context.l10n.dialogClear, - onChanged: (value) => setState(() => _query = value), - onClear: () => setState(() => _query = ''), + return MornyeSettingsTheme( + child: CustomScrollView( + slivers: [ + AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB( + tokens.gapLg + wideInset, + tokens.gapSm, + tokens.gapLg + wideInset, + tokens.gapSm, + ), + child: AppSearchField( + controller: _searchController, + focusNode: _searchFocusNode, + hintText: context.l10n.settingsSearchHint, + clearTooltip: context.l10n.dialogClear, + onChanged: (value) => setState(() => _query = value), + onClear: () => setState(() => _query = ''), + ), ), ), - ), - ...body, - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), - ], + ...body, + const NavBarSliverSpacer(), + const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + ], + ), ); } } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 584d396e..f002f896 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; @@ -170,20 +171,21 @@ class _SetupScreenState extends ConsumerState { } Future _showAndroid11StorageDialog() { - return showDialog( + return showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupStorageAccessRequired), content: Text( '${context.l10n.setupStorageAccessMessageAndroid11}\n\n' '${context.l10n.setupAllowAccessToManageFiles}', ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupOpenSettings), ), @@ -219,19 +221,20 @@ class _SetupScreenState extends ConsumerState { } Future _showPermissionDeniedDialog(String permissionType) async { - await showDialog( + await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupPermissionRequired(permissionType)), content: Text( context.l10n.setupPermissionRequiredMessage(permissionType), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(context); openAppSettings(); @@ -281,19 +284,20 @@ class _SetupScreenState extends ConsumerState { if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) { final defaultDir = await _getDefaultDirectory(); if (mounted) { - final useDefault = await showDialog( + final useDefault = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupUseDefaultFolder), content: Text( '${context.l10n.setupNoFolderSelected}\n\n$defaultDir', ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault), ), diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 5e85a03e..f54e5993 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -58,7 +58,9 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { context: context, useRootNavigator: true, isScrollControlled: true, - backgroundColor: colorScheme.surface, + backgroundColor: context.isMornye + ? colorScheme.surfaceContainerLow + : colorScheme.surface, builder: (sheetContext) => _EditMetadataSheet( colorScheme: colorScheme, initialValues: initialValues, @@ -118,18 +120,18 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showAppDialog( context: screenContext, - useRootNavigator: true, - builder: (dialogContext) => AlertDialog( + builder: (dialogContext) => AppAlertDialog( title: Text(dialogContext.l10n.trackDeleteConfirmTitle), content: Text(dialogContext.l10n.trackDeleteConfirmMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(dialogContext.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () async { var fileDeleted = true; if (_isLocalItem) { @@ -180,10 +182,8 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { } }); }, - child: Text( - dialogContext.l10n.dialogDelete, - style: TextStyle(color: colorScheme.error), - ), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(dialogContext.l10n.dialogDelete), ), ], ), diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 24fb10a6..fd888085 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -308,10 +308,9 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ColorScheme colorScheme, int? fileSize, ) { - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), + return _metadataSectionSurface( + context, + colorScheme, child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -319,13 +318,19 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { children: [ Row( children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.primary), + Icon( + context.isMornye ? CupertinoIcons.info : Icons.info_outline, + size: 20, + color: colorScheme.primary, + ), const SizedBox(width: 8), - Text( - context.l10n.trackMetadata, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Expanded( + child: Text( + context.l10n.trackMetadata, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), ], @@ -365,6 +370,13 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { } else { buttonLabel = context.l10n.trackOpenInSpotify; } + if (context.isMornye) { + return HeaderFilledButton( + icon: CupertinoIcons.arrow_up_right_square, + label: buttonLabel, + onPressed: () => _openServiceUrl(context), + ); + } return OutlinedButton.icon( onPressed: () => _openServiceUrl(context), icon: const Icon(Icons.open_in_new, size: 18), @@ -578,6 +590,28 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { metadata.label == 'Amazon ASIN' || metadata.label == 'Tidal ID' || metadata.label == 'Qobuz ID'; + if (context.isMornye) { + return MornyeMetadataRow( + label: metadata.label, + value: metadata.value, + showDivider: metadata != items.last, + trailing: isCopyable + ? Icon( + CupertinoIcons.doc_on_doc, + size: 16, + color: colorScheme.onSurfaceVariant, + ) + : null, + onTap: isCopyable + ? () => _copyToClipboard(context, metadata.rawValue) + : null, + onLongPress: () => _showMetadataCopySheet( + context, + metadata: metadata, + allMetadata: items, + ), + ); + } return InkWell( onTap: isCopyable ? () => _copyToClipboard(context, metadata.rawValue) @@ -719,10 +753,9 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { final resolvedQuality = _displayAudioQuality; final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), + return _metadataSectionSurface( + context, + colorScheme, child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -731,16 +764,20 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { Row( children: [ Icon( - Icons.folder_outlined, + context.isMornye + ? CupertinoIcons.folder + : Icons.folder_outlined, size: 20, color: colorScheme.primary, ), const SizedBox(width: 8), - Text( - context.l10n.trackFileInfo, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Expanded( + child: Text( + context.l10n.trackFileInfo, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), ], @@ -933,7 +970,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ), const SizedBox(width: 8), Icon( - Icons.copy, + context.isMornye ? CupertinoIcons.doc_on_doc : Icons.copy, size: 18, color: colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 15a60380..534d96ff 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -248,6 +248,8 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { context: context, useRootNavigator: true, isScrollControlled: true, + backgroundColor: context.isMornye ? Colors.transparent : null, + elevation: context.isMornye ? 0 : null, builder: (sheetContext) => BatchConvertSheet( formats: formats, title: context.l10n.trackConvertTitle, @@ -492,20 +494,21 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { required String date, required List tracks, }) { - showDialog( + showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.cueSplitConfirmTitle), content: Text( dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(dialogContext); _performCueSplit( @@ -745,10 +748,10 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { bool keepOriginal = false, }) { final isLossless = isLosslessConversionTarget(targetFormat); - showDialog( + showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( keepOriginal @@ -782,11 +785,12 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(dialogContext); _performConversion( diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 0e5c3f17..f97a5c5a 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -284,7 +284,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Flexible( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16), - child: SettingsGroup( + child: _editorOptions( children: [ for (var index = 0; index < options.length; index++) _metadataProviderOption( @@ -356,7 +356,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Flexible( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16), - child: SettingsGroup( + child: _editorOptions( children: [ for (var index = 0; index < options.length; index++) Column( @@ -368,7 +368,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { vertical: 4, ), leading: Icon( - options[index].value == 0 + context.isMornye + ? CupertinoIcons.photo + : options[index].value == 0 ? Icons.photo_size_select_actual_outlined : Icons.photo_size_select_large_outlined, color: options[index].value == currentValue @@ -379,7 +381,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { selected: options[index].value == currentValue, selectedColor: cs.primary, trailing: options[index].value == currentValue - ? const Icon(Icons.check_rounded) + ? Icon( + context.isMornye + ? CupertinoIcons.checkmark + : Icons.check_rounded, + ) : null, onTap: () => Navigator.pop( sheetContext, @@ -432,13 +438,19 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { vertical: 4, ), leading: Icon( - icon, + context.isMornye ? mornyeIconFor(icon) : icon, color: selected ? cs.primary : cs.onSurfaceVariant, ), title: Text(name, maxLines: 2, overflow: TextOverflow.ellipsis), selected: selected, selectedColor: cs.primary, - trailing: selected ? const Icon(Icons.check_rounded) : null, + trailing: selected + ? Icon( + context.isMornye + ? CupertinoIcons.checkmark + : Icons.check_rounded, + ) + : null, onTap: () => Navigator.pop(context, id), ), if (showDivider) @@ -1274,7 +1286,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { builder: (sheetContext) => ListView( padding: const EdgeInsets.only(bottom: 16), children: [ - SettingsGroup( + _editorOptions( children: [ for (var index = 0; index < candidates.length; index++) ...[ Builder( @@ -2137,16 +2149,20 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), if (_saving) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), child: SizedBox( width: 22, height: 22, - child: CircularProgressIndicator(strokeWidth: 2), + child: context.isMornye + ? const CupertinoActivityIndicator() + : const CircularProgressIndicator( + strokeWidth: 2, + ), ), ) else - FilledButton.icon( + AppActionButton( onPressed: _save, icon: const Icon(Icons.check, size: 18), label: Text(context.l10n.dialogSave), @@ -2378,6 +2394,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { runSpacing: 4, children: _fieldDefs.keys.map((key) { final selected = _autoFillFields.contains(key); + if (context.isMornye) { + return MornyeFilterChip( + label: _fieldLabel(key), + selected: selected, + glass: false, + tonal: false, + icon: selected ? CupertinoIcons.checkmark : null, + onTap: _fetching + ? null + : () => setState(() { + _invalidateAutoFillPreview(); + if (selected) { + _autoFillFields.remove(key); + } else { + _autoFillFields.add(key); + } + }), + ); + } return FilterChip( label: Text(_fieldLabel(key)), selected: selected, @@ -2406,7 +2441,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 14), SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + outlined: context.isMornye, + glass: false, onPressed: (_fetching || _saving || _autoFillFields.isEmpty) ? null : _fetchAutoFillPreview, @@ -2440,7 +2477,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 8), SizedBox( width: double.infinity, - child: OutlinedButton.icon( + child: AppActionButton( + outlined: true, + glass: false, onPressed: (_fetching || _fetchingMusicBrainz || _saving) ? null : _fetchFromMusicBrainz, @@ -2476,12 +2515,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return Container( width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant), - ), + padding: context.isMornye + ? const EdgeInsets.symmetric(vertical: 12) + : const EdgeInsets.all(12), + decoration: context.isMornye + ? null + : BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2570,7 +2613,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 6), SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + outlined: context.isMornye, + glass: false, onPressed: (_fetching || _saving) ? null : _applyAutoFillPreview, icon: const Icon(Icons.check), label: Text(context.l10n.editMetadataAutoFillApply), @@ -2619,11 +2664,36 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } + Widget _editorOptions({required List children}) { + if (!context.isMornye) return SettingsGroup(children: children); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ); + } + Widget _quickSelectButton({ required String label, required VoidCallback onTap, required ColorScheme cs, }) { + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + onPressed: _fetching ? null : onTap, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.primary), + ), + ); + } return InkWell( onTap: _fetching ? null : onTap, borderRadius: BorderRadius.circular(16), @@ -2668,7 +2738,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Row( children: [ Expanded( - child: OutlinedButton.icon( + child: AppActionButton( + outlined: true, + glass: false, onPressed: _saving ? null : _pickCoverImage, icon: const Icon(Icons.image_outlined), label: Text( @@ -2846,6 +2918,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { /// Fill for input fields, one step apart from the card so each field reads as /// a distinct surface in light/dark/AMOLED. Color _fieldFill(ColorScheme cs) { + if (context.isMornye) return Colors.transparent; final isDark = Theme.of(context).brightness == Brightness.dark; return isDark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface) @@ -2878,36 +2951,50 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), ), - TextField( - controller: controller, - keyboardType: keyboard, - inputFormatters: inputFormatters, - maxLines: maxLines, - cursorColor: cs.primary, - style: Theme.of(context).textTheme.bodyLarge, - decoration: InputDecoration( - hintText: hint, - filled: true, - fillColor: _fieldFill(cs), - isDense: true, - border: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide(color: cs.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 15, + if (context.isMornye) ...[ + CupertinoTextField( + controller: controller, + keyboardType: keyboard, + inputFormatters: inputFormatters, + maxLines: maxLines, + placeholder: hint, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), + decoration: null, + ), + const Divider(height: 0.5), + ] else + TextField( + controller: controller, + keyboardType: keyboard, + inputFormatters: inputFormatters, + maxLines: maxLines, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: _fieldFill(cs), + isDense: true, + border: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: cs.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 15, + ), ), ), - ), ], ), ); @@ -2922,22 +3009,30 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final cs = widget.colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 14), - child: Material( - color: _fieldFill(cs), - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: SwitchListTile.adaptive( - value: value, - onChanged: onChanged, - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text(label, style: Theme.of(context).textTheme.bodyLarge), - subtitle: Text( - subtitle, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + child: Column( + children: [ + Material( + color: _fieldFill(cs), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: AppSwitchListTile( + adaptive: true, + value: value, + onChanged: onChanged, + contentPadding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 4 : 16, + ), + title: Text(label, style: Theme.of(context).textTheme.bodyLarge), + subtitle: Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), ), - ), + if (context.isMornye) const Divider(height: 0.5), + ], ), ); } @@ -2949,6 +3044,53 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { bool enabled = true, }) { final cs = widget.colorScheme; + if (context.isMornye) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + CupertinoButton( + color: _fieldFill(cs), + disabledColor: Colors.transparent, + borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), + onPressed: enabled ? onTap : null, + child: Row( + children: [ + Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: enabled ? cs.onSurface : cs.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 16, + color: cs.primary, + ), + ], + ), + ), + const Divider(height: 0.5), + ], + ), + ); + } final radius = BorderRadius.circular(14); return Padding( padding: const EdgeInsets.only(bottom: 14), @@ -3019,10 +3161,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }) { final cs = widget.colorScheme; final collapsible = onHeaderTap != null; + const inset = 16.0; final headerRow = Row( children: [ - Icon(icon, size: 20, color: cs.primary), + Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + size: 20, + color: cs.primary, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -3039,7 +3186,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, child: Icon( - Icons.expand_more, + context.isMornye + ? CupertinoIcons.chevron_down + : Icons.expand_more, size: 22, color: cs.onSurfaceVariant, ), @@ -3051,43 +3200,56 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ? InkWell( onTap: onHeaderTap, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + padding: EdgeInsets.fromLTRB(inset, 16, inset, 16), child: headerRow, ), ) : Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: EdgeInsets.fromLTRB(inset, 16, inset, 8), child: headerRow, ); + final content = AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + if (children.isNotEmpty) + Padding( + padding: EdgeInsets.fromLTRB(inset, 0, inset, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], + ), + ); return Padding( padding: const EdgeInsets.only(bottom: 12), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - color: settingsGroupColor(context), - shape: _sectionCardShape(cs), - clipBehavior: Clip.antiAlias, - child: AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - header, - if (children.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ), - ), - ], - ), - ), - ), + child: context.isMornye + ? Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: content, + ), + ) + : Card( + elevation: 0, + margin: EdgeInsets.zero, + color: settingsGroupColor(context), + shape: _sectionCardShape(cs), + clipBehavior: Clip.antiAlias, + child: content, + ), ); } } diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index a6a79b9c..ef7a09d0 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -6,10 +6,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { final hasDisplaySource = (hasDisplayLyrics || _isInstrumental) && _lyricsSource?.trim().isNotEmpty == true; - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), + return _metadataSectionSurface( + context, + colorScheme, child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -18,22 +17,28 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { Row( children: [ Icon( - Icons.lyrics_outlined, + context.isMornye + ? CupertinoIcons.quote_bubble + : Icons.lyrics_outlined, size: 20, color: colorScheme.primary, ), const SizedBox(width: 8), - Text( - context.l10n.trackLyrics, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Expanded( + child: Text( + context.l10n.trackLyrics, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), - const Spacer(), if (hasDisplayLyrics) IconButton( - icon: const Icon(Icons.copy, size: 20), + icon: Icon( + context.isMornye ? CupertinoIcons.doc_on_doc : Icons.copy, + size: 20, + ), onPressed: () => _copyToClipboard(context, _lyrics!), tooltip: context.l10n.trackCopyLyrics, ), diff --git a/lib/screens/track_metadata_mornye.dart b/lib/screens/track_metadata_mornye.dart new file mode 100644 index 00000000..1b4137ce --- /dev/null +++ b/lib/screens/track_metadata_mornye.dart @@ -0,0 +1,174 @@ +part of 'track_metadata_screen.dart'; + +extension _TrackMetadataMornye on _TrackMetadataScreenState { + Widget _buildMornyePage(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final artwork = resolveMetadataArtworkSource( + embeddedCoverPath: + _embeddedCoverPreviewPath ?? + DownloadedEmbeddedCoverResolver.resolve(_filePath), + localCoverPath: _localCoverPath, + remoteCoverUrl: _coverUrl, + ); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragEnd: _handleHorizontalDragEnd, + child: Scaffold( + body: CustomScrollView( + controller: scrollController, + slivers: [ + SliverAppBar( + pinned: true, + centerTitle: true, + title: Text(context.l10n.trackMetadata), + leadingWidth: 64, + leading: Padding( + padding: const EdgeInsets.only(left: 16), + child: HeaderCircleButton( + icon: CupertinoIcons.chevron_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: _popWithMetadataResult, + ), + ), + actionsPadding: const EdgeInsets.only(right: 16), + actions: [ + Builder( + builder: (buttonContext) => HeaderCircleButton( + icon: CupertinoIcons.ellipsis, + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + onPressed: () => _showOptionsMenu( + context, + ref, + scheme, + anchor: mornyeMenuAnchor(buttonContext), + ), + ), + ), + ], + ), + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: adaptiveContentMaxWidth( + MediaQuery.sizeOf(context).width, + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Hero( + tag: _coverHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 70, + child: PlayerArtwork( + artUri: artwork, + colorScheme: scheme, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExplicitTrackTitle( + title: trackName, + explicit: isExplicit, + style: theme.textTheme.titleLarge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + artistName, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + if (albumName.trim().isNotEmpty) + Text( + albumName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium + ?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + if (_hasCheckedFile && !_fileExists) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + context.l10n.trackFileNotFound, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: adaptiveContentMaxWidth( + MediaQuery.sizeOf(context).width, + ), + ), + child: _buildAnimatedTrackContent(context, ref, scheme), + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox(height: context.navBarBottomInset), + ), + ], + ), + ), + ); + } + + Widget _metadataSectionSurface( + BuildContext context, + ColorScheme scheme, { + required Widget child, + }) { + if (context.isMornye) { + return Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: child, + ), + ); + } + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: _sectionCardShape(scheme), + child: child, + ); + } +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 8a7fc2e9..de319a92 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2,6 +2,15 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:flutter/cupertino.dart' + show + CupertinoButton, + CupertinoIcons, + CupertinoTextField, + CupertinoActivityIndicator; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -43,8 +52,14 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; import 'package:spotiflac_android/utils/saf_display_path.dart'; import 'package:spotiflac_android/theme/cover_palette.dart' show HeaderPalette; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart' - show HeaderMetaRow, HeaderMetaItem; + show HeaderMetaRow, HeaderMetaItem, HeaderCircleButton, HeaderFilledButton; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; @@ -61,6 +76,7 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; part 'track_metadata_screen_cover.dart'; part 'track_metadata_screen_display.dart'; part 'track_metadata_screen_menu.dart'; +part 'track_metadata_mornye.dart'; part 'track_metadata_edit_sheet.dart'; part 'track_metadata_cards.dart'; @@ -657,6 +673,7 @@ class _TrackMetadataScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) return _buildMornyePage(context); final colorScheme = Theme.of(context).colorScheme; final expandedHeight = calculateExpandedHeight(context); final bottomInset = context.navBarBottomInset; @@ -796,6 +813,29 @@ class _TrackMetadataScreenState extends ConsumerState ColorScheme colorScheme, bool fileExists, ) { + if (context.isMornye) { + return Row( + children: [ + Expanded( + child: HeaderFilledButton( + icon: CupertinoIcons.play_fill, + label: context.l10n.trackMetadataPlay, + onPressed: fileExists + ? () => _openFile(context, rawFilePath) + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: HeaderFilledButton( + icon: CupertinoIcons.trash, + label: context.l10n.trackMetadataDelete, + onPressed: () => _confirmDelete(context, ref, colorScheme), + ), + ), + ], + ); + } return Row( children: [ Expanded( @@ -872,6 +912,27 @@ class _MetadataOptionTile extends StatelessWidget { : colorScheme.onSurfaceVariant; final titleColor = option.destructive ? colorScheme.error : null; + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + onPressed: onTap, + child: Row( + children: [ + Expanded( + child: Text( + option.label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: titleColor ?? colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 16), + Icon(mornyeIconFor(option.icon), color: iconColor, size: 22), + ], + ), + ); + } + return InkWell( onTap: onTap, splashColor: colorScheme.primary.withValues(alpha: 0.12), diff --git a/lib/screens/track_metadata_screen_menu.dart b/lib/screens/track_metadata_screen_menu.dart index 2c2af811..14abb66e 100644 --- a/lib/screens/track_metadata_screen_menu.dart +++ b/lib/screens/track_metadata_screen_menu.dart @@ -1,198 +1,263 @@ // ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member part of 'track_metadata_screen.dart'; -// The overflow options sheet and its header cover. +// The overflow menu and the Material sheet's header cover. extension _TrackMetadataMenu on _TrackMetadataScreenState { void _showOptionsMenu( BuildContext screenContext, WidgetRef ref, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + Rect? anchor, + }) { + Widget buildMenu(BuildContext sheetContext) { + final l10n = sheetContext.l10n; + + final options = <_MetadataOption>[ + if (_fileExists) + _MetadataOption( + icon: Icons.playlist_play, + label: l10n.trackPlayNext, + onTap: () => _enqueueThis(ref, playNext: true), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.queue_music, + label: l10n.trackAddToQueue, + onTap: () => _enqueueThis(ref, playNext: false), + ), + if (albumName.trim().isNotEmpty) + _MetadataOption( + icon: Icons.album_outlined, + label: l10n.homeGoToAlbum, + onTap: () => _goToStoredAlbum(screenContext), + ), + _MetadataOption( + icon: Icons.copy_outlined, + label: l10n.trackCopyFilePath, + dividerAbove: _fileExists, + onTap: () => _copyToClipboard(screenContext, cleanFilePath), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.edit_outlined, + label: l10n.trackEditMetadata, + onTap: () => + _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + _MetadataOption( + icon: Icons.image_outlined, + label: l10n.trackSaveCoverArt, + onTap: _saveCoverArt, + ), + if (!_isLocalItem) + _MetadataOption( + icon: Icons.lyrics_outlined, + label: l10n.trackSaveLyrics, + onTap: _saveLyrics, + ), + if (_fileExists) + _MetadataOption( + icon: Icons.travel_explore, + label: l10n.trackReEnrich, + onTap: _reEnrichMetadata, + ), + if (_fileExists && _isConvertibleFormat) + _MetadataOption( + icon: Icons.swap_horiz, + label: l10n.trackConvertFormat, + onTap: () => _showConvertSheet(screenContext), + ), + if (_fileExists && !_isCueFile) + _MetadataOption( + icon: Icons.graphic_eq, + label: l10n.trackReplayGain, + onTap: () => _rescanReplayGain(), + ), + if (_fileExists && _isCueFile) + _MetadataOption( + icon: Icons.call_split, + label: l10n.cueSplitTitle, + onTap: () => _showCueSplitSheet(screenContext), + ), + if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) + _MetadataOption( + icon: Icons.open_in_new, + label: l10n.trackOpenOn, + dividerAbove: true, + onTap: () => OpenOnPlatformSheet.show( + screenContext, + spotifyId: _spotifyId ?? '', + isrc: isrc ?? '', + ), + ), + _MetadataOption( + icon: Icons.share_outlined, + label: l10n.trackMetadataShare, + dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), + onTap: () => _shareFile(screenContext), + ), + _MetadataOption( + icon: Icons.delete_outline, + label: l10n.trackRemoveFromDevice, + destructive: true, + onTap: () => _confirmDelete(screenContext, ref, colorScheme), + ), + ]; + + if (sheetContext.isMornye) { + final groups = >[]; + for (final option in options) { + if (option.icon == Icons.share_outlined || + option.icon == Icons.edit_outlined) { + continue; + } + if (groups.isEmpty || option.dividerAbove || option.destructive) { + groups.add([]); + } + groups.last.add( + MornyeMenuAction( + icon: mornyeIconFor(option.icon), + label: option.label, + destructive: option.destructive, + onPressed: () => + _closeOptionsMenuAndRun(sheetContext, option.onTap), + ), + ); + } + return MornyeContextMenu( + quickActions: [ + if (_fileExists) ...[ + MornyeMenuAction( + icon: CupertinoIcons.play_fill, + label: l10n.trackMetadataPlay, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _openFile(screenContext, rawFilePath), + ), + ), + MornyeMenuAction( + icon: CupertinoIcons.pencil, + label: l10n.trackEditMetadata, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + ), + ], + MornyeMenuAction( + icon: CupertinoIcons.share_solid, + label: l10n.trackMetadataShare, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _shareFile(screenContext), + ), + ), + ], + groups: groups, + ); + } + + Widget buildContent({ScrollController? controller}) => SafeArea( + top: false, + child: SingleChildScrollView( + controller: controller, + primary: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildOptionsHeaderCover(colorScheme), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 8), + Builder( + builder: (context) { + final items = [ + for (int i = 0; i < options.length; i++) ...[ + if (options[i].dividerAbove && i != 0) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), + ), + _MetadataOptionTile( + option: options[i], + colorScheme: colorScheme, + onTap: () => _closeOptionsMenuAndRun( + sheetContext, + options[i].onTap, + ), + ), + ], + ]; + return SettingsGroup(children: items); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, + ), + child: buildContent(), + ); + } + + if (screenContext.isMornye) { + showMornyeContextMenu( + context: screenContext, + anchor: anchor, + builder: buildMenu, + ); + return; + } showModalBottomSheet( context: screenContext, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, - builder: (sheetContext) { - final l10n = sheetContext.l10n; - - final options = <_MetadataOption>[ - if (_fileExists) - _MetadataOption( - icon: Icons.playlist_play, - label: l10n.trackPlayNext, - onTap: () => _enqueueThis(ref, playNext: true), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.queue_music, - label: l10n.trackAddToQueue, - onTap: () => _enqueueThis(ref, playNext: false), - ), - if (albumName.trim().isNotEmpty) - _MetadataOption( - icon: Icons.album_outlined, - label: l10n.homeGoToAlbum, - onTap: () => _goToStoredAlbum(screenContext), - ), - _MetadataOption( - icon: Icons.copy_outlined, - label: l10n.trackCopyFilePath, - dividerAbove: _fileExists, - onTap: () => _copyToClipboard(screenContext, cleanFilePath), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.edit_outlined, - label: l10n.trackEditMetadata, - onTap: () => - _showEditMetadataSheet(screenContext, ref, colorScheme), - ), - if (!_isLocalItem && (_coverUrl != null || _fileExists)) - _MetadataOption( - icon: Icons.image_outlined, - label: l10n.trackSaveCoverArt, - onTap: _saveCoverArt, - ), - if (!_isLocalItem) - _MetadataOption( - icon: Icons.lyrics_outlined, - label: l10n.trackSaveLyrics, - onTap: _saveLyrics, - ), - if (_fileExists) - _MetadataOption( - icon: Icons.travel_explore, - label: l10n.trackReEnrich, - onTap: _reEnrichMetadata, - ), - if (_fileExists && _isConvertibleFormat) - _MetadataOption( - icon: Icons.swap_horiz, - label: l10n.trackConvertFormat, - onTap: () => _showConvertSheet(screenContext), - ), - if (_fileExists && !_isCueFile) - _MetadataOption( - icon: Icons.graphic_eq, - label: l10n.trackReplayGain, - onTap: () => _rescanReplayGain(), - ), - if (_fileExists && _isCueFile) - _MetadataOption( - icon: Icons.call_split, - label: l10n.cueSplitTitle, - onTap: () => _showCueSplitSheet(screenContext), - ), - if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) - _MetadataOption( - icon: Icons.open_in_new, - label: l10n.trackOpenOn, - dividerAbove: true, - onTap: () => OpenOnPlatformSheet.show( - screenContext, - spotifyId: _spotifyId ?? '', - isrc: isrc ?? '', - ), - ), - _MetadataOption( - icon: Icons.share_outlined, - label: l10n.trackMetadataShare, - dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), - onTap: () => _shareFile(screenContext), - ), - _MetadataOption( - icon: Icons.delete_outline, - label: l10n.trackRemoveFromDevice, - destructive: true, - onTap: () => _confirmDelete(screenContext, ref, colorScheme), - ), - ]; - - return SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: _buildOptionsHeaderCover(colorScheme), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(sheetContext) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(sheetContext) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - const SizedBox(height: 8), - SettingsGroup( - children: [ - for (int i = 0; i < options.length; i++) ...[ - if (options[i].dividerAbove && i != 0) - Divider( - height: 1, - thickness: 1, - color: colorScheme.outlineVariant.withValues( - alpha: 0.3, - ), - ), - _MetadataOptionTile( - option: options[i], - colorScheme: colorScheme, - onTap: () => _closeOptionsMenuAndRun( - sheetContext, - options[i].onTap, - ), - ), - ], - ], - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ); - }, + builder: buildMenu, ); } diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index 0b604b1a..c58b0e97 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/widgets/scroll_edge_fade.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; class TutorialScreen extends ConsumerStatefulWidget { const TutorialScreen({super.key}); @@ -409,29 +410,16 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( + AppSearchField( controller: _controller, + hintText: context.l10n.tutorialSearchHint, + clearTooltip: context.l10n.dialogClear, + onClear: () => setState(() => _showResult = false), onChanged: (value) { setState(() { _showResult = value.isNotEmpty; }); }, - style: TextStyle(color: colorScheme.onSurface, fontSize: 16), - decoration: InputDecoration( - hintText: context.l10n.tutorialSearchHint, - hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), - prefixIcon: Icon(Icons.search, color: colorScheme.primary), - filled: true, - fillColor: colorScheme.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - ), ), AnimatedSize( diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 06752452..8b626d26 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -13,12 +13,14 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/utils/saf_display_path.dart'; @@ -97,6 +99,8 @@ Future showBatchConvertSheet( context: modalContext, useRootNavigator: true, isScrollControlled: true, + backgroundColor: modalContext.isMornye ? Colors.transparent : null, + elevation: modalContext.isMornye ? 0 : null, builder: (sheetContext) => BatchConvertSheet( formats: formats, title: sheetTitle, @@ -165,9 +169,9 @@ Future _performBatchConversion( final isLossless = isLosslessConversionTarget(targetFormat); final losslessLabels = context.l10n.losslessConversionLabels; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( keepOriginal @@ -197,11 +201,13 @@ Future _performBatchConversion( ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(ctx, true), child: Text(context.l10n.trackConvertFormat), ), @@ -546,19 +552,21 @@ Future runBatchReplayGain( onConfirmOpen?.call(); - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.replayGainBatchConfirmTitle), content: Text( ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.l10n.replayGainBatchConfirmTitle), ), diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 200c0959..8c87cd21 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -77,16 +77,21 @@ class LibraryDatabase { columns: ['id', 'path'], where: localSources, ); + final localSourceIds = sources.map((source) => source['id']).toSet(); final rows = await txn.query( 'library', - columns: ['id', 'file_path', 'cover_path'], - where: - 'source_id IN (SELECT id FROM library_sources WHERE $localSources)', + columns: ['id', 'source_id', 'file_path', 'cover_path'], ); final batch = txn.batch(); for (final row in rows) { final updates = {}; for (final column in ['file_path', 'cover_path']) { + // Bookmarks own external audio paths, but extracted covers always + // live in our sandbox, including covers from bookmarked folders. + if (column == 'file_path' && + !localSourceIds.contains(row['source_id'])) { + continue; + } final previous = row[column] as String?; if (previous == null) continue; final current = rebaseIosSandboxPath(previous, documents.path); diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index e05f5dba..46ffd71b 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; @@ -28,17 +29,18 @@ Future queueLocalTracksAsFlac( return; } - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.queueFlacAction), content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(ctx, true), child: Text(context.l10n.queueFlacAction), ), diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index d97f73c2..474e7043 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -7,10 +7,14 @@ import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:audio_session/audio_session.dart' show AudioSession, AudioSessionConfiguration, AudioInterruptionType; import 'package:audioplayers/audioplayers.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; +import 'package:spotiflac_android/utils/ios_container_paths.dart'; +import 'package:spotiflac_android/utils/playback_artwork.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -117,20 +121,36 @@ class PlayableMedia { if (explicit) 'explicit': true, }; - static PlayableMedia? fromJson(Map json) { + static PlayableMedia? fromJson( + Map json, { + String? iosDocumentsPath, + }) { final id = json['id'] as String?; final source = json['source'] as String?; if (id == null || id.isEmpty || source == null || source.isEmpty) { return null; } final durationMs = (json['durationMs'] as num?)?.toInt(); + var artwork = json['artUri'] as String?; + if (artwork != null && iosDocumentsPath != null) { + final uri = Uri.tryParse(artwork); + if (uri != null && uri.scheme == 'file' && uri.host.isEmpty) { + try { + final path = uri.toFilePath(); + final rebased = rebaseIosSandboxPath(path, iosDocumentsPath); + if (rebased != path) artwork = Uri.file(rebased).toString(); + } on UnsupportedError { + // Leave malformed or non-local artwork URIs to the image fallback. + } + } + } return PlayableMedia( id: id, source: source, title: json['title'] as String? ?? '', artist: json['artist'] as String? ?? '', album: json['album'] as String? ?? '', - artUri: json['artUri'] as String?, + artUri: artwork, duration: (durationMs != null && durationMs > 0) ? Duration(milliseconds: durationMs) : null, @@ -1571,15 +1591,45 @@ Future restorePersistedPlaybackSession() async { final items = []; final keptOriginalIndices = []; + final iosDocumentsPath = Platform.isIOS + ? (await getApplicationDocumentsDirectory()).path + : null; + var artworkRelocated = false; for (var i = 0; i < rawMedia.length; i++) { final entry = rawMedia[i]; if (entry is! Map) continue; - final media = PlayableMedia.fromJson(Map.from(entry)); + final media = PlayableMedia.fromJson( + Map.from(entry), + iosDocumentsPath: iosDocumentsPath, + ); if (media == null) continue; if (!media.isContentUri && !await File(media.source).exists()) { continue; } - items.add(media); + final artwork = await resolveRestoredArtworkUri( + media.artUri, + loadLibraryCoverPath: () async { + final database = LibraryDatabase.instance; + final row = await database.getById(media.id); + if (row != null) return row['coverPath'] as String?; + final matches = await database.findByTrackAndArtist( + media.title, + media.artist, + ); + for (final row in matches) { + if (row['filePath'] == media.source) { + return row['coverPath'] as String?; + } + } + return null; + }, + ); + items.add( + artwork == media.artUri + ? media + : PlayableMedia.fromJson({...media.toJson(), 'artUri': artwork})!, + ); + artworkRelocated |= artwork != entry['artUri']; keptOriginalIndices.add(i); } if (items.isEmpty) { @@ -1607,7 +1657,7 @@ Future restorePersistedPlaybackSession() async { index: index, position: position, shuffle: session['shuffle'] == true, - queueNeedsRewrite: items.length != rawMedia.length, + queueNeedsRewrite: items.length != rawMedia.length || artworkRelocated, repeatMode: AudioServiceRepeatMode.values.firstWhere( (mode) => mode.name == session['repeat'], orElse: () => AudioServiceRepeatMode.none, diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart index d229c3c6..e3edd65a 100644 --- a/lib/services/shell_navigation_service.dart +++ b/lib/services/shell_navigation_service.dart @@ -3,6 +3,76 @@ import 'package:flutter/widgets.dart'; enum ShellTab { home, library, repository, settings } class ShellNavigationService { + static final homeSearchRequests = ValueNotifier(0); + static final chromeBrightness = ValueNotifier(null); + static final chromeSurface = ValueNotifier(null); + static final _visiblePages = , Route?>{}; + static final _chromeScopes = + < + Object, + ({ModalRoute route, Brightness brightness, Color? surface}) + >{}; + static bool _chromeUpdateScheduled = false; + static int _homeSearchGeneration = 0; + + static void setChromeBrightness({ + required Object owner, + required ModalRoute route, + required Brightness brightness, + Color? surface, + }) { + final scope = (route: route, brightness: brightness, surface: surface); + if (_chromeScopes[owner] == scope) return; + _chromeScopes[owner] = scope; + _scheduleChromeUpdate(); + } + + static void clearChromeBrightness(Object owner) { + _chromeScopes.remove(owner); + _scheduleChromeUpdate(); + } + + static void _scheduleChromeUpdate() { + if (_chromeUpdateScheduled) return; + _chromeUpdateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _chromeUpdateScheduled = false; + final activePage = _visiblePages[_activeTabNavigatorKey()]; + Brightness? brightness; + Color? surface; + for (final scope in _chromeScopes.values) { + if (identical(scope.route, activePage)) { + brightness = scope.brightness; + surface = scope.surface; + } + } + chromeBrightness.value = brightness; + chromeSurface.value = surface; + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + static void requestHomeSearch() { + if (!requestTab(ShellTab.home)) return; + final generation = ++_homeSearchGeneration; + final owner = _tabSelectionOwner; + // PageView creates Home lazily. Deliver the request after its navigator + // and focus listener mount, rather than losing the first tap on another tab. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (generation != _homeSearchGeneration || + !identical(owner, _tabSelectionOwner) || + _currentTabIndex != 0) { + return; + } + homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); + homeSearchRequests.value++; + // Home scrolls to its search field and requests focus after layout. + // Scheduling that frame avoids waiting for another tap to wake it up. + WidgetsBinding.instance.ensureVisualUpdate(); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + static final GlobalKey homeTabNavigatorKey = GlobalKey(); static final GlobalKey libraryTabNavigatorKey = @@ -40,16 +110,81 @@ class ShellNavigationService { required int currentTabIndex, required bool showRepoTab, }) { + if (_currentTabIndex == currentTabIndex && _showRepoTab == showRepoTab) { + return; + } _currentTabIndex = currentTabIndex; _showRepoTab = showRepoTab; + _scheduleChromeUpdate(); } - static NavigatorState? activeTabNavigator() { - if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; - if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; + static GlobalKey? _activeTabNavigatorKey() { + if (_currentTabIndex == 0) return homeTabNavigatorKey; + if (_currentTabIndex == 1) return libraryTabNavigatorKey; if (_showRepoTab && _currentTabIndex == 2) { - return repoTabNavigatorKey.currentState; + return repoTabNavigatorKey; } return null; } + + static NavigatorState? activeTabNavigator() => + _activeTabNavigatorKey()?.currentState; +} + +/// Track the top page in each tab. Dialogs keep the underlying page's contrast; +/// a pushed album, another tab, or a back gesture restores its own appearance. +class ShellChromeObserver extends NavigatorObserver { + ShellChromeObserver(this.navigatorKey); + + final GlobalKey navigatorKey; + final _routes = >[]; + + void _update() { + Route? page; + for (final route in _routes.reversed) { + if (route is PageRoute) { + page = route; + break; + } + } + ShellNavigationService._visiblePages[navigatorKey] = page; + ShellNavigationService._scheduleChromeUpdate(); + } + + @override + void didPush(Route route, Route? previousRoute) { + _routes.add(route); + _update(); + } + + @override + void didPop(Route route, Route? previousRoute) { + _routes.remove(route); + _update(); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routes.remove(route); + _update(); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + final index = oldRoute == null ? -1 : _routes.indexOf(oldRoute); + if (index >= 0) { + if (newRoute == null) { + _routes.removeAt(index); + } else { + _routes[index] = newRoute; + } + } + _update(); + } + + void detach() { + _routes.clear(); + ShellNavigationService._visiblePages.remove(navigatorKey); + ShellNavigationService._scheduleChromeUpdate(); + } } diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index 8caccd08..6528c59a 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -4,6 +4,8 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:material_ui/material_ui.dart' as material_ui; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class DynamicColorWrapper extends ConsumerWidget { final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) @@ -15,6 +17,14 @@ class DynamicColorWrapper extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final themeSettings = ref.watch(themeProvider); + if (themeSettings.style == AppThemeStyle.mornye) { + return builder( + MornyeTheme.build(Brightness.light), + MornyeTheme.build(Brightness.dark), + themeSettings.themeMode, + ); + } + return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { ColorScheme lightScheme; diff --git a/lib/theme/mornye_icons.dart b/lib/theme/mornye_icons.dart new file mode 100644 index 00000000..cc4aff50 --- /dev/null +++ b/lib/theme/mornye_icons.dart @@ -0,0 +1,161 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; + +/// Cupertino counterparts for the shared Settings and extension UI symbols. +IconData mornyeIconFor(IconData icon) { + if (icon.fontFamily == CupertinoIcons.iconFont) return icon; + return switch (icon) { + Icons.extension || + Icons.extension_outlined || + Icons.apps || + Icons.grid_view || + Icons.grid_view_outlined || + Icons.grid_view_rounded => CupertinoIcons.square_grid_2x2, + Icons.palette_outlined || + Icons.color_lens_outlined => CupertinoIcons.paintbrush, + Icons.library_music || + Icons.library_music_outlined || + Icons.playlist_play_outlined || + Icons.queue_music_outlined => CupertinoIcons.music_note_list, + Icons.sell_outlined || + Icons.label_outline || + Icons.tag => CupertinoIcons.tag, + Icons.lyrics || + Icons.lyrics_outlined || + Icons.subtitles_outlined || + Icons.forum_outlined => CupertinoIcons.quote_bubble, + Icons.download || + Icons.download_outlined || + Icons.file_download_outlined || + Icons.downloading_outlined => CupertinoIcons.arrow_down_circle, + Icons.folder_outlined || + Icons.folder_special_outlined => CupertinoIcons.folder, + Icons.create_new_folder_outlined => CupertinoIcons.folder_badge_plus, + Icons.storage_outlined || + Icons.sd_storage_outlined || + Icons.memory_outlined || + Icons.usb_rounded => CupertinoIcons.tray, + Icons.settings_backup_restore || + Icons.history || + Icons.history_toggle_off_outlined => CupertinoIcons.clock, + Icons.article_outlined || Icons.source_outlined => CupertinoIcons.doc_text, + Icons.favorite_outline || Icons.favorite_border => CupertinoIcons.heart, + Icons.favorite => CupertinoIcons.heart_fill, + Icons.arrow_back || Icons.chevron_left => CupertinoIcons.chevron_back, + Icons.close => CupertinoIcons.xmark, + Icons.play_arrow || Icons.play_arrow_rounded => CupertinoIcons.play_fill, + Icons.shuffle => CupertinoIcons.shuffle, + Icons.playlist_add || Icons.queue_music => CupertinoIcons.text_badge_plus, + Icons.edit_outlined => CupertinoIcons.pencil, + Icons.edit_note => CupertinoIcons.pencil_ellipsis_rectangle, + Icons.fingerprint => CupertinoIcons.number, + Icons.playlist_add_check || + Icons.playlist_add_check_circle => CupertinoIcons.text_badge_checkmark, + Icons.add_circle_outline => CupertinoIcons.add_circled, + Icons.format_list_numbered => CupertinoIcons.list_number, + Icons.checklist => CupertinoIcons.list_bullet, + Icons.calendar_today_outlined => CupertinoIcons.calendar, + Icons.preview_outlined => CupertinoIcons.doc_text_search, + Icons.check || Icons.check_rounded => CupertinoIcons.checkmark, + Icons.download_for_offline_outlined => CupertinoIcons.arrow_down_circle, + Icons.call_split => CupertinoIcons.arrow_branch, + Icons.download_rounded => CupertinoIcons.arrow_down, + Icons.info_outline => CupertinoIcons.info, + Icons.settings_outlined => CupertinoIcons.gear_alt, + Icons.tune || + Icons.tune_outlined || + Icons.table_chart_outlined => CupertinoIcons.slider_horizontal_3, + Icons.build_outlined => CupertinoIcons.wrench, + Icons.people_alt_outlined || + Icons.people_outline => CupertinoIcons.person_2, + Icons.person || + Icons.person_outline || + Icons.person_outline_rounded || + Icons.person_search_outlined => CupertinoIcons.person, + Icons.group_remove_outlined => + CupertinoIcons.person_crop_circle_badge_xmark, + Icons.photo_size_select_large_outlined || + Icons.image_outlined || + Icons.wallpaper => CupertinoIcons.photo, + Icons.graphic_eq || + Icons.graphic_eq_outlined || + Icons.graphic_eq_rounded => CupertinoIcons.waveform, + Icons.filter_alt_outlined || + Icons.filter_list || + Icons.filter_list_outlined => CupertinoIcons.line_horizontal_3_decrease, + Icons.travel_explore || + Icons.language || + Icons.language_outlined || + Icons.public || + Icons.translate || + Icons.translate_outlined => CupertinoIcons.globe, + Icons.text_fields || + Icons.text_fields_outlined => CupertinoIcons.textformat, + Icons.record_voice_over_outlined => CupertinoIcons.mic, + Icons.music_note || + Icons.audio_file_outlined || + Icons.audiotrack || + Icons.music_note_outlined => CupertinoIcons.music_note, + Icons.speed || Icons.speed_outlined => CupertinoIcons.gauge, + Icons.auto_fix_high || + Icons.auto_fix_high_outlined || + Icons.auto_awesome_outlined => CupertinoIcons.wand_stars, + Icons.wifi => CupertinoIcons.wifi, + Icons.dynamic_feed_outlined || + Icons.tab_outlined => CupertinoIcons.square_on_square, + Icons.security_outlined => CupertinoIcons.lock_shield, + Icons.lan_outlined || Icons.link => CupertinoIcons.link, + Icons.sync || Icons.autorenew_rounded => CupertinoIcons.arrow_2_circlepath, + Icons.refresh || + Icons.update || + Icons.system_update => CupertinoIcons.arrow_clockwise, + Icons.content_copy_outlined || + Icons.content_copy_rounded || + Icons.copy_all_rounded || + Icons.copy_outlined || + Icons.copy => CupertinoIcons.doc_on_doc, + Icons.swap_horiz || + Icons.compare_arrows || + Icons.difference_outlined || + Icons.alt_route => CupertinoIcons.arrow_right_arrow_left, + Icons.cleaning_services_outlined => CupertinoIcons.sparkles, + Icons.delete_outline || + Icons.delete_forever || + Icons.delete_sweep_outlined => CupertinoIcons.trash, + Icons.open_in_new || + Icons.open_in_new_rounded || + Icons.open_in_browser => CupertinoIcons.arrow_up_right_square, + Icons.play_circle_outline => CupertinoIcons.play_circle, + Icons.share || + Icons.share_outlined || + Icons.ios_share => CupertinoIcons.share, + Icons.brightness_2 || Icons.dark_mode => CupertinoIcons.moon, + Icons.light_mode => CupertinoIcons.sun_max, + Icons.contrast || + Icons.brightness_auto => CupertinoIcons.circle_lefthalf_fill, + Icons.animation => CupertinoIcons.play_rectangle, + Icons.blur_on => CupertinoIcons.drop, + Icons.new_releases => CupertinoIcons.star_circle, + Icons.bug_report || Icons.bug_report_outlined => CupertinoIcons.ant, + Icons.timer_outlined => CupertinoIcons.timer, + Icons.timer_off_outlined => CupertinoIcons.timer, + Icons.bedtime_outlined => CupertinoIcons.moon_zzz, + Icons.explore_outlined => CupertinoIcons.compass, + Icons.vpn_key_outlined => CupertinoIcons.lock, + Icons.bookmark_outline => CupertinoIcons.bookmark, + Icons.low_priority => CupertinoIcons.sort_down, + Icons.search || Icons.manage_search => CupertinoIcons.search, + Icons.add => CupertinoIcons.add, + Icons.code => CupertinoIcons.chevron_left_slash_chevron_right, + Icons.lightbulb_outline => CupertinoIcons.lightbulb, + Icons.telegram => CupertinoIcons.paperplane, + Icons.check => CupertinoIcons.check_mark, + Icons.album || Icons.album_outlined => CupertinoIcons.square_stack, + Icons.playlist_play => CupertinoIcons.music_note_list, + Icons.video_library => CupertinoIcons.play_rectangle, + Icons.podcasts => CupertinoIcons.mic, + Icons.monitor_heart_outlined => CupertinoIcons.waveform_path_ecg, + Icons.warning_amber_rounded => CupertinoIcons.exclamationmark_triangle, + _ => CupertinoIcons.gear_alt, + }; +} diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart new file mode 100644 index 00000000..4ea4b9e3 --- /dev/null +++ b/lib/theme/mornye_theme.dart @@ -0,0 +1,320 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; + +/// Flutter counterpart of Mornye's iPhone visual system. Reference values come +/// from AccentColor.colorset, Components.swift, PlayerViews.swift and Motion.md +/// in the Mornye project; keep this palette independent of wallpaper colors. +@immutable +class MornyeTheme extends ThemeExtension { + const MornyeTheme({this.chromeSurface}); + + final Color? chromeSurface; + + /// A single translucent fill for controls inside an existing glass surface. + static Color controlFill(BuildContext context, {bool enabled = true}) { + final theme = Theme.of(context); + return theme.colorScheme.onSurface.withValues( + alpha: !enabled + ? 0.04 + : theme.brightness == Brightness.dark + ? 0.10 + : 0.07, + ); + } + + /// Separators blend with their group, keeping definition on pages and sheets. + static Color metadataDividerColor(BuildContext context) { + final theme = Theme.of(context); + return theme.colorScheme.onSurface.withValues( + alpha: theme.brightness == Brightness.dark ? 0.14 : 0.17, + ); + } + + static double chromeOpacity(BuildContext context) => + Theme.of(context).extension()?.chromeSurface != null + ? 0.42 + : 0.70; + + static const lightAccent = Color.fromRGBO(204, 46, 51, 1); + static const darkAccent = Color.fromRGBO(224, 61, 60, 1); + + static final tokens = AppTokens.standard.copyWith( + radiusBadge: 5, + radiusThumb: 6, + radiusCover: 10, + radiusControl: 12, + radiusCard: 12, + radiusSheet: 32, + coverMini: 38, + headerExpandedTitleSize: 34, + headerCollapsedTitleSize: 17, + motionFast: const Duration(milliseconds: 180), + motionMedium: const Duration(milliseconds: 220), + motionSlow: const Duration(milliseconds: 380), + ); + + // Theme construction includes seeded color generation and typography. Reuse + // identical themes across routes; bound artwork-derived variants so browsing + // many artists cannot grow the cache indefinitely. Platform is part of the + // key because it controls fonts and route transitions. + static final _themeCache = + <(TargetPlatform, Brightness, Color?), ThemeData>{}; + + static ThemeData build(Brightness brightness, {Color? chromeSurface}) { + final key = (defaultTargetPlatform, brightness, chromeSurface); + final cached = _themeCache.remove(key); + if (cached != null) { + _themeCache[key] = cached; + return cached; + } + final theme = _build(brightness, chromeSurface: chromeSurface); + if (_themeCache.length >= 16) { + _themeCache.remove(_themeCache.keys.first); + } + _themeCache[key] = theme; + return theme; + } + + static ThemeData _build(Brightness brightness, {Color? chromeSurface}) { + final dark = brightness == Brightness.dark; + final accent = dark ? darkAccent : lightAccent; + final foreground = dark ? Colors.white : Colors.black; + final surface = dark ? Colors.black : Colors.white; + final grouped = dark ? const Color(0xff1c1c1e) : const Color(0xfff2f2f7); + final secondary = dark ? const Color(0xff98989f) : const Color(0xff6c6c70); + final scheme = + ColorScheme.fromSeed( + seedColor: accent, + brightness: brightness, + ).copyWith( + primary: accent, + onPrimary: Colors.white, + primaryContainer: grouped, + onPrimaryContainer: accent, + secondary: accent, + onSecondary: Colors.white, + secondaryContainer: grouped, + onSecondaryContainer: foreground, + surface: surface, + onSurface: foreground, + surfaceContainerLowest: surface, + surfaceContainerLow: grouped, + surfaceContainer: grouped, + surfaceContainerHigh: chromeSurface == null + ? (dark ? const Color(0xff2c2c2e) : grouped) + : Color.lerp(chromeSurface, Colors.black, 0.16), + surfaceContainerHighest: dark + ? const Color(0xff3a3a3c) + : const Color(0xffe5e5ea), + onSurfaceVariant: secondary, + outline: secondary, + outlineVariant: dark + ? const Color(0xff38383a) + : const Color(0xffc6c6c8), + surfaceTint: Colors.transparent, + ); + // Apple platforms use the installed SF system faces. Inter is bundled for + // other platforms; Cupertino's family names alone cannot provide SF there. + final apple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + final type = CupertinoThemeData(brightness: brightness).textTheme; + final text = type.textStyle.copyWith( + inherit: true, + color: foreground, + fontFamily: apple ? type.textStyle.fontFamily : 'Inter', + ); + final display = type.navLargeTitleTextStyle.copyWith( + inherit: true, + color: foreground, + fontFamily: apple ? type.navLargeTitleTextStyle.fontFamily : 'Inter', + ); + // Supply complete styles: replacing a Material role with a bare TextStyle + // loses its system family and retains Material tracking in other roles. + final typography = TextTheme( + displayLarge: display.copyWith(fontSize: 57), + displayMedium: display.copyWith(fontSize: 45), + displaySmall: display.copyWith(fontSize: 36), + headlineLarge: display, + headlineMedium: display.copyWith(fontSize: 28, letterSpacing: 0.36), + headlineSmall: display.copyWith(fontSize: 22, letterSpacing: 0.35), + titleLarge: display.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: 0.38, + ), + titleMedium: text.copyWith(fontWeight: FontWeight.w500), + titleSmall: text.copyWith( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.23, + ), + bodyLarge: text, + bodyMedium: text.copyWith(fontSize: 15, letterSpacing: -0.23), + bodySmall: text.copyWith( + fontSize: 13, + letterSpacing: -0.08, + color: secondary, + ), + labelLarge: text.copyWith(fontWeight: FontWeight.w600), + labelMedium: text.copyWith( + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: -0.08, + ), + labelSmall: text.copyWith( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: -0.24, + color: secondary, + ), + ); + final base = ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: surface, + fontFamily: text.fontFamily, + splashFactory: NoSplash.splashFactory, + extensions: >[ + MornyeTheme(chromeSurface: chromeSurface), + tokens, + ], + ); + final controlShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ); + return base.copyWith( + textTheme: typography, + primaryTextTheme: typography.apply( + bodyColor: scheme.onPrimary, + displayColor: scheme.onPrimary, + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + }, + ), + appBarTheme: AppBarTheme( + backgroundColor: surface, + foregroundColor: accent, + elevation: 0, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + centerTitle: true, + titleTextStyle: text.copyWith(fontWeight: FontWeight.w600), + ), + cardTheme: CardThemeData( + color: grouped, + elevation: 0, + shape: controlShape, + ), + dividerTheme: DividerThemeData( + color: scheme.outlineVariant, + thickness: 0.5, + space: 0.5, + ), + listTileTheme: ListTileThemeData( + iconColor: accent, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + shape: controlShape, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + shape: controlShape, + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(shape: controlShape), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(shape: controlShape), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: grouped, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: grouped, + surfaceTintColor: Colors.transparent, + constraints: const BoxConstraints(maxWidth: 640), + shape: tokens.sheetShape, + ), + dialogTheme: DialogThemeData( + backgroundColor: grouped, + shape: controlShape, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: scheme.surfaceContainerHigh, + contentTextStyle: text.copyWith(fontSize: 15, color: foreground), + actionTextColor: accent, + disabledActionTextColor: secondary, + closeIconColor: foreground, + elevation: 0, + insetPadding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)), + ), + ), + ); + } + + @override + MornyeTheme copyWith({Color? chromeSurface}) => + MornyeTheme(chromeSurface: chromeSurface ?? this.chromeSurface); + + @override + MornyeTheme lerp(covariant MornyeTheme? other, double t) => MornyeTheme( + chromeSurface: Color.lerp(chromeSurface, other?.chromeSurface, t), + ); +} + +extension MornyeThemeContext on BuildContext { + bool get isMornye => Theme.of(this).extension() != null; +} + +/// Settings uses iOS's inset grouped surfaces while the music library keeps +/// its artwork-led, plain background. +class MornyeSettingsTheme extends StatelessWidget { + const MornyeSettingsTheme({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) return child; + final theme = Theme.of(context); + final dark = theme.brightness == Brightness.dark; + final background = dark ? Colors.black : const Color(0xfff2f2f7); + final group = dark ? const Color(0xff1c1c1e) : Colors.white; + return Theme( + data: theme.copyWith( + scaffoldBackgroundColor: background, + extensions: [ + for (final extension in theme.extensions.values) + if (extension is AppTokens) + extension.copyWith(radiusCard: 24) + else + extension, + ], + colorScheme: theme.colorScheme.copyWith( + surface: background, + surfaceContainer: group, + ), + appBarTheme: theme.appBarTheme.copyWith(backgroundColor: background), + ), + child: ColoredBox(color: background, child: child), + ); + } +} diff --git a/lib/utils/confirm_and_delete_tracks.dart b/lib/utils/confirm_and_delete_tracks.dart index ca233d6d..cc18f86b 100644 --- a/lib/utils/confirm_and_delete_tracks.dart +++ b/lib/utils/confirm_and_delete_tracks.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; /// Shows a delete-confirmation dialog, deletes the given [ids] one by one via /// [deleteItem], then shows a "deleted N tracks" snackbar. @@ -17,17 +18,20 @@ Future confirmAndDeleteTracks({ Future Function(List ids)? persistDeletedItems, required VoidCallback onExitSelectionMode, }) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.downloadedAlbumDeleteSelected), content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, diff --git a/lib/utils/nav_bar_inset.dart b/lib/utils/nav_bar_inset.dart index 0ca43da5..1397cfb8 100644 --- a/lib/utils/nav_bar_inset.dart +++ b/lib/utils/nav_bar_inset.dart @@ -10,3 +10,17 @@ import 'package:flutter/widgets.dart'; extension NavBarInset on BuildContext { double get navBarBottomInset => MediaQuery.paddingOf(this).bottom; } + +/// Keeps animated navigation-bar insets local to the trailing spacer instead +/// of rebuilding the screen and its lists on every chrome animation frame. +class NavBarSliverSpacer extends StatelessWidget { + const NavBarSliverSpacer({super.key, this.bottomInset}); + + /// An explicit inset for surfaces that do not inherit the shell's padding. + final double? bottomInset; + + @override + Widget build(BuildContext context) => SliverToBoxAdapter( + child: SizedBox(height: bottomInset ?? context.navBarBottomInset), + ); +} diff --git a/lib/utils/playback_artwork.dart b/lib/utils/playback_artwork.dart new file mode 100644 index 00000000..026d9a4b --- /dev/null +++ b/lib/utils/playback_artwork.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +/// A saved queue may outlive the scan directory that supplied its artwork. +/// Consult the Library only when the saved local image is missing. +Future resolveRestoredArtworkUri( + String? artwork, { + required Future Function() loadLibraryCoverPath, +}) async { + try { + if (artwork != null && artwork.isNotEmpty) { + final uri = Uri.parse(artwork); + if (uri.hasScheme && uri.scheme != 'file') return artwork; + final path = uri.scheme == 'file' ? uri.toFilePath() : artwork; + if (await File(path).exists()) return artwork; + } + final cover = await loadLibraryCoverPath(); + if (cover == null || cover.isEmpty) return artwork; + final path = cover.startsWith('file://') + ? Uri.parse(cover).toFilePath() + : cover; + if (await File(path).exists()) return Uri.file(path).toString(); + } catch (_) { + // Missing artwork or an unavailable Library must not discard playback. + } + return artwork; +} diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 7fbc1804..601aa17a 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -1,8 +1,14 @@ import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Collapsing album-detail header shared by the album, local-album, and @@ -32,6 +38,8 @@ class AlbumDetailHeader extends StatelessWidget { this.appBarTitle, this.leading, this.backgroundColor, + this.immersive = false, + this.squareArtwork = true, }); final String title; @@ -73,6 +81,10 @@ class AlbumDetailHeader extends StatelessWidget { final Widget? leading; final Color? backgroundColor; + final bool immersive; + + /// Ordinary cover art stays 1:1; provider-supplied banners may fill a header. + final bool squareArtwork; /// Shrinks long titles so up to three lines fit the header. double _titleFontSize() { @@ -84,14 +96,164 @@ class AlbumDetailHeader extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye && (coverBuilder != null || immersive)) { + final scheme = Theme.of(context).colorScheme; + return HeaderPalette( + scheme: scheme, + child: Builder( + builder: (context) => + SliverMainAxisGroup(slivers: buildSlivers(context)), + ), + ); + } return CoverPaletteBuilder( imageSource: paletteSource, builder: (context, headerScheme) => _buildAppBar(context, headerScheme), ); } + /// Keep the toolbar at the collection scroll-view level so it stays pinned + /// after the dynamically sized artwork and metadata have scrolled away. + List buildSlivers(BuildContext context) { + if (!context.isMornye || (coverBuilder == null && !immersive)) { + return [this]; + } + final scheme = Theme.of(context).colorScheme; + final edgeInset = detailHeaderEdgeInset(context); + final artworkSize = MediaQuery.sizeOf(context).width.clamp(0.0, 440.0); + // Keep the artwork and text in normal sliver flow: Dynamic Type and long + // album names must grow the header instead of overflowing a fixed height. + return [ + // Only the toolbar needs safe-area padding. Reading it on the caller's + // context rebuilds all header slivers as the navigation bar folds. + Builder( + builder: (context) => SliverAppBar( + pinned: true, + expandedHeight: immersive + ? squareArtwork + ? artworkSize - MediaQuery.paddingOf(context).top + : MediaQuery.sizeOf(context).width.clamp(280.0, 440.0) + : null, + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: immersive + ? FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + if (squareArtwork) + Align( + alignment: Alignment.topCenter, + child: SizedBox.square( + dimension: artworkSize, + child: background, + ), + ) + else + background, + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.52, 1], + colors: [ + Colors.black.withValues(alpha: 0.12), + scheme.surface.withValues(alpha: 0), + scheme.surface, + ], + ), + ), + ), + ], + ), + ) + : null, + title: AnimatedOpacity( + opacity: showTitleInAppBar || appBarTitle != null ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: Text( + appBarTitle ?? title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: + leading ?? + HeaderCircleButton( + icon: CupertinoIcons.chevron_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + actionsPadding: EdgeInsets.only(right: edgeInset), + actions: appBarActions, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28), + child: Column( + children: [ + if (!immersive) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.16), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: LayoutBuilder( + builder: (context, constraints) => + coverBuilder!(context, constraints.maxWidth), + ), + ), + ), + ), + ), + if (!immersive) const SizedBox(height: 22), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[const SizedBox(height: 5), subtitle!], + if (meta != null) ...[const SizedBox(height: 5), meta!], + if (actions != null) ...[ + const SizedBox(height: 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: actions!, + ), + ], + ], + ), + ), + ), + ]; + } + Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) { final tokens = context.tokens; + final plainArtworkHeader = context.isMornye && coverBuilder != null; // iOS does not add horizontal safe-area padding in portrait. Give toolbar // controls and header content an explicit inset so circular actions do not // sit against the glass edge on either iPhone or iPad. Android retains its @@ -135,33 +297,36 @@ class AlbumDetailHeader extends StatelessWidget { background: Stack( fit: StackFit.expand, children: [ - if (blurAndScrimBackground) + if (plainArtworkHeader) + ColoredBox(color: headerScheme.surface) + else if (blurAndScrimBackground) ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: background, ) else background, - if (blurAndScrimBackground) + if (!plainArtworkHeader && blurAndScrimBackground) ColoredBox(color: scrimColor.withValues(alpha: 0.4)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - scrimColor.withValues(alpha: 0), - scrimColor.withValues(alpha: 0.92), - ], + if (!plainArtworkHeader) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + scrimColor.withValues(alpha: 0), + scrimColor.withValues(alpha: 0.92), + ], + ), ), ), ), - ), Positioned( left: 20 + iosEdgeInset, right: 20 + iosEdgeInset, @@ -184,7 +349,9 @@ class AlbumDetailHeader extends StatelessWidget { height: coverSize, decoration: BoxDecoration( borderRadius: BorderRadius.circular( - tokens.radiusControl, + plainArtworkHeader + ? tokens.radiusCover + : tokens.radiusControl, ), boxShadow: [ BoxShadow( @@ -248,7 +415,9 @@ class AlbumDetailHeader extends StatelessWidget { leading ?? IconButton.filledTonal( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), + icon: Icon( + context.isMornye ? Icons.chevron_left : Icons.arrow_back, + ), style: IconButton.styleFrom( minimumSize: Size.square(tokens.minTouchTarget), backgroundColor: headerScheme.surfaceContainerHigh.withValues( @@ -285,6 +454,28 @@ class AlbumPlayActions extends StatelessWidget { Widget build(BuildContext context) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); + if (context.isMornye) { + return Row( + children: [ + Expanded( + child: HeaderFilledButton( + icon: CupertinoIcons.play_fill, + label: playLabel, + onPressed: onPlay, + tonal: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MornyeHeaderButton( + icon: CupertinoIcons.shuffle, + label: shuffleTooltip, + onPressed: onShuffle, + ), + ), + ], + ); + } return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -329,16 +520,26 @@ class HeaderFilledButton extends StatelessWidget { required this.icon, required this.label, required this.onPressed, + this.tonal = false, }); final IconData icon; final String label; final VoidCallback? onPressed; + final bool tonal; @override Widget build(BuildContext context) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); + if (context.isMornye) { + return _MornyeHeaderButton( + icon: mornyeIconFor(icon), + label: label, + onPressed: onPressed, + prominent: !tonal, + ); + } return FilledButton.icon( onPressed: onPressed, icon: Icon(icon, size: 18), @@ -357,13 +558,14 @@ class HeaderFilledButton extends StatelessWidget { /// Circular header icon button (add-to-playlist, love-all, ...). Sized to the /// minimum touch target and tinted from the [HeaderPalette]. -class HeaderCircleButton extends StatelessWidget { +class HeaderCircleButton extends ConsumerWidget { const HeaderCircleButton({ super.key, required this.icon, required this.tooltip, required this.onPressed, this.iconColor, + this.tonal = false, }); final IconData icon; @@ -373,10 +575,62 @@ class HeaderCircleButton extends StatelessWidget { /// Overrides the palette foreground, e.g. to mark an active "loved" state. final Color? iconColor; + /// Uses the same translucent fill as the local album's Play/Shuffle pills. + final bool tonal; + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); + if (context.isMornye) { + if (tonal) { + return Tooltip( + message: tooltip, + child: SizedBox.square( + dimension: 44, + child: CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(24), + color: scheme.onSurface.withValues( + alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06, + ), + onPressed: onPressed, + child: Icon( + mornyeIconFor(icon), + size: 22, + color: onPressed == null + ? scheme.onSurfaceVariant + : iconColor ?? scheme.primary, + ), + ), + ), + ); + } + final blurEnabled = + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider); + final button = IconButton( + onPressed: onPressed, + tooltip: tooltip, + icon: Icon(mornyeIconFor(icon), size: 22), + style: IconButton.styleFrom( + minimumSize: const Size.square(44), + foregroundColor: iconColor ?? scheme.onSurface, + ), + ); + return Center( + widthFactor: 1, + child: MornyeGlass.navigation( + radius: 24, + tintColor: scheme.brightness == Brightness.dark + ? Colors.black + : Colors.white, + tintOpacity: scheme.brightness == Brightness.dark ? 0.45 : 0.55, + blurEnabled: blurEnabled, + child: button, + ), + ); + } return IconButton.filledTonal( onPressed: onPressed, icon: Icon(icon, size: 22), @@ -390,6 +644,63 @@ class HeaderCircleButton extends StatelessWidget { } } +class _MornyeHeaderButton extends StatelessWidget { + const _MornyeHeaderButton({ + required this.icon, + required this.label, + required this.onPressed, + this.prominent = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onPressed; + final bool prominent; + + @override + Widget build(BuildContext context) { + final scheme = HeaderPalette.of(context); + final foreground = onPressed == null + ? scheme.onSurfaceVariant + : prominent + ? scheme.onPrimary + : scheme.primary; + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 50), + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(28), + color: prominent + ? scheme.primary + : scheme.onSurface.withValues( + alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06, + ), + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: foreground), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: foreground, + ), + ), + ), + ], + ), + ), + ); + } +} + /// "•"-joined row of [HeaderMetaItem]s shown under the header subtitle /// (track count, duration, quality, ...). class HeaderMetaRow extends StatelessWidget { @@ -436,7 +747,7 @@ class HeaderMetaItem extends StatelessWidget { Widget build(BuildContext context) { final scheme = HeaderPalette.of(context); final textStyle = TextStyle( - color: scheme.onSurface, + color: context.isMornye ? scheme.onSurfaceVariant : scheme.onSurface, fontSize: 13, fontWeight: FontWeight.w500, ); diff --git a/lib/widgets/album_track_tile.dart b/lib/widgets/album_track_tile.dart index 346a5885..beadc574 100644 --- a/lib/widgets/album_track_tile.dart +++ b/lib/widgets/album_track_tile.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -58,10 +60,15 @@ class AlbumTrackTile extends StatelessWidget { trailing: IconButton( tooltip: context.l10n.tooltipPlay, onPressed: onPlay, - icon: Icon(Icons.play_arrow, color: colorScheme.primary), + icon: Icon( + context.isMornye ? CupertinoIcons.play_fill : Icons.play_arrow, + color: colorScheme.primary, + ), style: IconButton.styleFrom( minimumSize: Size.square(context.tokens.minTouchTarget), - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + backgroundColor: context.isMornye + ? Colors.transparent + : colorScheme.primaryContainer.withValues(alpha: 0.3), ), ), ); diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 6e5f5d95..9ae544b3 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; /// Wraps a child in a staggered fade-in + slide-up animation. /// @@ -194,6 +195,24 @@ class TrackListSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return ShimmerLoading( + child: SingleChildScrollView( + child: Column( + children: [ + if (showCoverHeader) + const _CollectionHeaderSkeleton(showSubtitle: false), + for (var index = 0; index < itemCount; index++) + _MornyeTrackSkeleton( + numbered: false, + index: index, + showPreview: true, + ), + ], + ), + ), + ); + } return ShimmerLoading( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -265,6 +284,23 @@ class AlbumTrackListSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return ShimmerLoading( + child: SingleChildScrollView( + child: Column( + children: [ + if (showCoverHeader) const _CollectionHeaderSkeleton(), + for (var index = 0; index < itemCount; index++) + _MornyeTrackSkeleton( + numbered: true, + index: index, + showPreview: true, + ), + ], + ), + ), + ); + } return ShimmerLoading( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -331,14 +367,87 @@ class AlbumTrackListSkeleton extends StatelessWidget { } } -/// Header skeleton matching the redesigned album/playlist header: a blurred -/// backdrop block with a centered square cover, title/subtitle bars, a meta -/// line (year + quality badges) and the action button row. +/// Mirrors each theme's collection header, including Mornye's full-width +/// square artwork and compact primary action between two circular controls. class _CollectionHeaderSkeleton extends StatelessWidget { - const _CollectionHeaderSkeleton(); + const _CollectionHeaderSkeleton({this.showSubtitle = true}); + + final bool showSubtitle; @override Widget build(BuildContext context) { + if (context.isMornye) { + return LayoutBuilder( + builder: (context, constraints) { + final coverSize = constraints.maxWidth.clamp(0.0, 440.0); + return Column( + children: [ + ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.52, 1], + colors: [Colors.white, Colors.white, Colors.transparent], + ).createShader(bounds), + child: SkeletonBox( + width: coverSize, + height: coverSize, + borderRadius: 0, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), + child: Column( + children: [ + const FractionallySizedBox( + widthFactor: 0.75, + child: SkeletonBox( + width: double.infinity, + height: 28, + borderRadius: 6, + ), + ), + if (showSubtitle) ...[ + const SizedBox(height: 5), + const FractionallySizedBox( + widthFactor: 0.5, + child: SkeletonBox( + width: double.infinity, + height: 24, + borderRadius: 5, + ), + ), + ], + const SizedBox(height: 5), + const SkeletonBox(width: 140, height: 16, borderRadius: 4), + const SizedBox(height: 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + Flexible( + child: SkeletonBox( + width: 172, + height: 50, + borderRadius: 25, + ), + ), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ); + } final screenWidth = MediaQuery.sizeOf(context).width; final coverSize = (screenWidth * 0.5).clamp(150.0, 210.0).toDouble(); @@ -403,6 +512,104 @@ class ArtistScreenSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return ShimmerLoading( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showCoverHeader) ...[ + const SkeletonBox( + width: double.infinity, + height: 342, + borderRadius: 0, + ), + const Center(child: SkeletonBox(width: 220, height: 36)), + const SizedBox(height: 16), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 24, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + SkeletonBox(width: 60, height: 60, borderRadius: 30), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ], + Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), + ), + child: const Row( + spacing: 14, + children: [ + SkeletonBox(width: 76, height: 76, borderRadius: 5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + SkeletonBox(width: 38, height: 12, borderRadius: 4), + FractionallySizedBox( + widthFactor: 0.9, + child: SkeletonBox( + width: double.infinity, + height: 18, + borderRadius: 4, + ), + ), + SkeletonBox(width: 62, height: 12, borderRadius: 4), + ], + ), + ), + SkeletonBox(width: 10, height: 14, borderRadius: 4), + ], + ), + ), + ), + if (showPopularSection) ...[ + const Padding( + padding: EdgeInsets.fromLTRB(20, 24, 20, 12), + child: SkeletonBox(width: 110, height: 24, borderRadius: 4), + ), + for (var index = 0; index < popularCount; index++) + _MornyeTrackSkeleton(numbered: false, index: index), + ], + const Padding( + padding: EdgeInsets.fromLTRB(20, 24, 20, 12), + child: SkeletonBox(width: 120, height: 24, borderRadius: 4), + ), + SizedBox( + height: 208, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: albumCount, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (_, index) => const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox(width: 156, height: 156, borderRadius: 8), + SizedBox(height: 8), + SkeletonBox(width: 120, height: 16, borderRadius: 4), + SizedBox(height: 4), + SkeletonBox(width: 45, height: 12, borderRadius: 4), + ], + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } final screenWidth = MediaQuery.sizeOf(context).width; return ShimmerLoading( child: SingleChildScrollView( @@ -513,6 +720,77 @@ class ArtistScreenSkeleton extends StatelessWidget { } } +class _MornyeTrackSkeleton extends StatelessWidget { + const _MornyeTrackSkeleton({ + required this.numbered, + required this.index, + this.showPreview = false, + }); + + final bool numbered; + final int index; + final bool showPreview; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + if (numbered) + const SizedBox( + width: 32, + child: Center( + child: SkeletonBox(width: 14, height: 16, borderRadius: 4), + ), + ) + else + const SkeletonBox(width: 48, height: 48, borderRadius: 4), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + widthFactor: 0.7 + (index % 3) * 0.1, + child: const SkeletonBox( + width: double.infinity, + height: 18, + borderRadius: 4, + ), + ), + const SizedBox(height: 6), + const FractionallySizedBox( + widthFactor: 0.5, + child: SkeletonBox( + width: double.infinity, + height: 14, + borderRadius: 4, + ), + ), + ], + ), + ), + const SizedBox(width: 20), + if (showPreview) ...[ + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + const SizedBox(width: 24), + ], + const SkeletonBox(width: 20, height: 6, borderRadius: 3), + ], + ), + ), + Padding( + padding: EdgeInsets.only(left: numbered ? 64 : 80, right: 20), + child: const Divider(height: 1, thickness: 0.5), + ), + ], + ); + } +} + /// Home search skeleton – mimics filter chips + sectioned results /// (Artists section with rounded card items, Albums section, etc.) class HomeSearchSkeleton extends StatelessWidget { @@ -558,6 +836,50 @@ class HomeSearchSkeleton extends StatelessWidget { double headerWidth, int itemCount, ) { + final mornye = context.isMornye; + final rows = List.generate(itemCount, (index) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: mornye ? 12 : 16, + vertical: mornye ? 12 : 10, + ), + child: Row( + children: [ + SkeletonBox( + width: mornye ? 56 : 48, + height: mornye ? 56 : 48, + borderRadius: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 100 + (index % 3) * 40, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 60 + (index % 2) * 25, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }); + final decoration = BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(mornye ? 24 : 20), + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -571,50 +893,24 @@ class HomeSearchSkeleton extends StatelessWidget { ], ), ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(20), + if (mornye) + for (var index = 0; index < rows.length; index++) + Container( + margin: EdgeInsets.fromLTRB( + 16, + 0, + 16, + index == rows.length - 1 ? 0 : 8, + ), + decoration: decoration, + child: rows[index], + ) + else + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: decoration, + child: Column(children: rows), ), - child: Column( - children: List.generate(itemCount, (index) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Row( - children: [ - const SkeletonBox(width: 48, height: 48, borderRadius: 24), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonBox( - width: 100 + (index % 3) * 40, - height: 14, - borderRadius: 4, - ), - const SizedBox(height: 6), - SkeletonBox( - width: 60 + (index % 2) * 25, - height: 12, - borderRadius: 4, - ), - ], - ), - ), - const SkeletonBox(width: 20, height: 20, borderRadius: 10), - ], - ), - ); - }), - ), - ), ], ); } diff --git a/lib/widgets/app_action_button.dart b/lib/widgets/app_action_button.dart new file mode 100644 index 00000000..0ac64493 --- /dev/null +++ b/lib/widgets/app_action_button.dart @@ -0,0 +1,98 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Shared sheet actions follow the selected design on every platform. +class AppActionButton extends StatelessWidget { + const AppActionButton({ + super.key, + required this.onPressed, + required this.icon, + required this.label, + this.style, + this.outlined = false, + this.glass = true, + this.tonal = false, + this.isDestructive = false, + this.fontSize = 17, + }); + + final VoidCallback? onPressed; + final Widget icon; + final Widget label; + final ButtonStyle? style; + final bool outlined; + final bool glass; + final bool tonal; + final bool isDestructive; + final double fontSize; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return outlined + ? OutlinedButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + style: style, + ) + : FilledButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + style: style, + ); + } + final scheme = Theme.of(context).colorScheme; + final prominent = isDestructive || !outlined; + final foreground = onPressed == null + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : prominent + ? scheme.onPrimary + : scheme.primary; + final action = CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(28), + color: prominent + ? scheme.primary + : tonal + ? MornyeTheme.controlFill(context) + : Colors.transparent, + disabledColor: !prominent && !tonal && !glass + ? Colors.transparent + : tonal && !prominent + ? MornyeTheme.controlFill(context, enabled: false) + : scheme.surfaceContainerHighest, + onPressed: onPressed, + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: foreground, + fontSize: fontSize, + fontWeight: FontWeight.w600, + ), + child: IconTheme( + data: IconThemeData(color: foreground, size: 20), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon case Icon(:final icon?)) + Icon(mornyeIconFor(icon), size: 20) + else + icon, + const SizedBox(width: 8), + Flexible(child: label), + ], + ), + ), + ), + ); + // These buttons sit inside glass sheets/groups; avoid another blur pass. + return prominent || tonal || !glass + ? action + : MornyeGlass.navigation(radius: 28, blurEnabled: false, child: action); + } +} diff --git a/lib/widgets/app_alert_dialog.dart b/lib/widgets/app_alert_dialog.dart new file mode 100644 index 00000000..c2497212 --- /dev/null +++ b/lib/widgets/app_alert_dialog.dart @@ -0,0 +1,291 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Dialog presentation follows the selected design, including on Android. +Future showAppDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + bool useRootNavigator = true, +}) { + if (context.isMornye) { + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + return navigator.push( + _MornyeDialogRoute( + context: context, + builder: (context) => themes.wrap(Builder(builder: builder)), + barrierDismissible: barrierDismissible, + ), + ); + } + return showDialog( + context: context, + builder: builder, + barrierDismissible: barrierDismissible, + useRootNavigator: useRootNavigator, + ); +} + +/// The confirmation glass also hosts larger dialogs such as release notes. +class AppDialogSurface extends StatelessWidget { + const AppDialogSurface({ + super.key, + required this.child, + this.maxWidth = 480, + this.backgroundColor, + this.shape, + }); + + final Widget child; + final double maxWidth; + final Color? backgroundColor; + final ShapeBorder? shape; + + @override + Widget build(BuildContext context) { + final mornye = context.isMornye; + return Dialog( + backgroundColor: mornye ? Colors.transparent : backgroundColor, + surfaceTintColor: mornye ? Colors.transparent : null, + elevation: mornye ? 0 : null, + shape: shape, + insetPadding: EdgeInsets.symmetric( + horizontal: mornye ? 24 : 40, + vertical: 24, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: mornye + ? MornyeGlassPanel.overlay( + child: SingleChildScrollView(child: child), + ) + : child, + ), + ); + } +} + +class _MornyeDialogRoute extends RawDialogRoute { + _MornyeDialogRoute({ + required BuildContext context, + required WidgetBuilder builder, + required super.barrierDismissible, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => + builder(context), + barrierColor: CupertinoDynamicColor.resolve( + kCupertinoModalBarrierColor, + context, + ), + barrierLabel: CupertinoLocalizations.of( + context, + ).modalBarrierDismissLabel, + transitionDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + ); + + // Use a timed exit instead of CupertinoDialogRoute's settling spring. + @override + Duration get reverseTransitionDuration => transitionDuration == Duration.zero + ? Duration.zero + : const Duration(milliseconds: 160); + + CurvedAnimation? _scaleAnimation; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (MediaQuery.disableAnimationsOf(context)) return child; + // Keep the backdrop in the page's compositing context. A fading dialog + // creates an opacity layer that hides the page from its BackdropFilter. + final scaleAnimation = _scaleAnimation ??= CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return ScaleTransition( + scale: scaleAnimation.drive(Tween(begin: 0.96, end: 1)), + child: child, + ); + } + + @override + void dispose() { + _scaleAnimation?.dispose(); + super.dispose(); + } +} + +class AppAlertDialog extends StatelessWidget { + const AppAlertDialog({ + super.key, + this.icon, + this.title, + this.content, + this.actions = const [], + this.backgroundColor, + this.shape, + }); + + final Widget? icon; + final Widget? title; + final Widget? content; + final List actions; + final Color? backgroundColor; + final ShapeBorder? shape; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return AlertDialog( + backgroundColor: backgroundColor, + shape: shape, + icon: icon, + title: title, + content: content, + actions: actions, + ); + } + final theme = Theme.of(context); + final primaryActions = actions.where( + (action) => + action is AppDialogAction && (action.isDestructive || action.filled), + ); + final secondaryActions = actions.where( + (action) => + action is! AppDialogAction || + (!action.isDestructive && !action.filled), + ); + final orderedActions = [...primaryActions, ...secondaryActions]; + return Dialog( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: MornyeGlassPanel.overlay( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (icon != null || title != null || content != null) + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (icon != null) ...[ + Align( + alignment: AlignmentDirectional.centerStart, + child: icon!, + ), + const SizedBox(height: 12), + ], + if (title != null) + DefaultTextStyle( + style: theme.textTheme.titleLarge!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + child: title!, + ), + if (title != null && content != null) + const SizedBox(height: 10), + if (content != null) + DefaultTextStyle( + style: theme.textTheme.bodyLarge!.copyWith( + fontSize: 17, + height: 1.35, + color: theme.colorScheme.onSurface, + ), + child: content!, + ), + ], + ), + ), + ), + for (var i = 0; i < orderedActions.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + orderedActions[i], + ], + ], + ), + ), + ), + ), + ); + } +} + +class AppDialogAction extends StatelessWidget { + const AppDialogAction({ + super.key, + required this.onPressed, + required this.child, + this.isDestructive = false, + this.isDefault = false, + this.filled = false, + this.outlined = false, + this.style, + }); + + final VoidCallback? onPressed; + final Widget child; + final bool isDestructive; + final bool isDefault; + final bool filled; + final bool outlined; + final ButtonStyle? style; + + @override + Widget build(BuildContext context) { + if (context.isMornye) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final color = onPressed == null + ? scheme.onSurfaceVariant + : isDestructive + ? scheme.primary + : scheme.onSurface; + return CupertinoButton( + onPressed: onPressed, + color: MornyeTheme.controlFill(context), + disabledColor: MornyeTheme.controlFill(context, enabled: false), + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: DefaultTextStyle( + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge!.copyWith( + color: color, + fontSize: 17, + fontWeight: isDestructive || isDefault || filled + ? FontWeight.w600 + : FontWeight.w500, + ), + child: IconTheme( + data: IconThemeData(color: color, size: 20), + child: child, + ), + ), + ); + } + return filled + ? FilledButton(onPressed: onPressed, style: style, child: child) + : outlined + ? OutlinedButton(onPressed: onPressed, style: style, child: child) + : TextButton(onPressed: onPressed, style: style, child: child); + } +} diff --git a/lib/widgets/app_announcement_dialog.dart b/lib/widgets/app_announcement_dialog.dart index 6fb9756c..d3332bee 100644 --- a/lib/widgets/app_announcement_dialog.dart +++ b/lib/widgets/app_announcement_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; class AppAnnouncementDialog extends StatelessWidget { final RemoteAnnouncement announcement; @@ -68,7 +69,8 @@ class AppAnnouncementDialog extends StatelessWidget { final actions = [ if (announcement.hasCta) - FilledButton( + AppDialogAction( + filled: true, onPressed: () => _openCta(context), child: Text(announcement.ctaLabel!), ), @@ -76,7 +78,7 @@ class AppAnnouncementDialog extends StatelessWidget { return PopScope( canPop: false, - child: AlertDialog( + child: AppAlertDialog( title: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -111,7 +113,7 @@ class AppAnnouncementDialog extends StatelessWidget { ), ), ), - actions: actions.isEmpty ? null : actions, + actions: actions, ), ); } @@ -122,7 +124,7 @@ Future showAppAnnouncementDialog( required RemoteAnnouncement announcement, required VoidCallback onDismiss, }) { - return showDialog( + return showAppDialog( context: context, barrierDismissible: false, builder: (context) => diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index c104d483..110e93e7 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' + show CupertinoButton, CupertinoDynamicColor, kCupertinoModalBarrierColor; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; /// The drag handle shown at the top of every modal sheet. /// @@ -131,7 +136,7 @@ class AppBottomSheet extends StatelessWidget { ), child: Text( subtitle!, - maxLines: 1, + maxLines: context.isMornye ? 3 : 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -179,7 +184,7 @@ Future showAppBottomSheet({ bool showHandle = true, Color? backgroundColor, }) { - return showModalBottomSheet( + return showAppModalBottomSheet( context: context, isScrollControlled: isScrollControlled, isDismissible: isDismissible, @@ -195,3 +200,128 @@ Future showAppBottomSheet({ ), ); } + +/// Adaptive surface for sheets which already supply their own header/layout. +Future showAppModalBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + bool isScrollControlled = false, + bool isDismissible = true, + bool enableDrag = true, + bool useRootNavigator = false, + bool useSafeArea = false, + BoxConstraints? constraints, + Color? backgroundColor, +}) { + final mornye = context.isMornye; + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + isDismissible: isDismissible, + enableDrag: enableDrag, + useRootNavigator: useRootNavigator, + useSafeArea: useSafeArea, + constraints: constraints, + backgroundColor: mornye ? Colors.transparent : backgroundColor, + barrierColor: mornye + ? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context) + : null, + elevation: mornye ? 0 : null, + builder: (context) => mornye + ? MornyeGlassPanel.overlay(child: Builder(builder: builder)) + : builder(context), + ); +} + +/// Flat rows sit directly on a sheet's glass; Material keeps its ListTile. +class AppSheetOption extends StatelessWidget { + const AppSheetOption({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + this.contentPadding, + this.enabled = true, + }); + + final Widget title; + final Widget? subtitle; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final EdgeInsetsGeometry? contentPadding; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return ListTile( + title: title, + subtitle: subtitle, + leading: leading, + trailing: trailing, + onTap: onTap, + contentPadding: contentPadding, + enabled: enabled, + ); + } + final theme = Theme.of(context); + final scheme = theme.colorScheme; + Widget adaptedIcon(Widget widget, double size) { + if (widget case Icon(:final icon?)) { + return Icon( + mornyeIconFor(icon), + size: size, + color: enabled ? widget.color ?? scheme.primary : theme.disabledColor, + ); + } + return widget; + } + + return CupertinoButton( + onPressed: enabled ? onTap : null, + padding: + contentPadding ?? + const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + if (leading != null) ...[ + adaptedIcon(leading!, 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: theme.textTheme.bodyLarge!.copyWith( + color: enabled ? scheme.onSurface : theme.disabledColor, + fontWeight: FontWeight.w500, + ), + child: title, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + DefaultTextStyle( + style: theme.textTheme.bodySmall!.copyWith( + color: enabled + ? scheme.onSurfaceVariant + : theme.disabledColor, + ), + child: subtitle!, + ), + ], + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 12), + adaptedIcon(trailing!, 20), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/app_choice_chip.dart b/lib/widgets/app_choice_chip.dart new file mode 100644 index 00000000..01168a07 --- /dev/null +++ b/lib/widgets/app_choice_chip.dart @@ -0,0 +1,75 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// A single tonal selection inside a sheet, without a second glass layer. +class AppChoiceChip extends StatelessWidget { + const AppChoiceChip({ + super.key, + required this.label, + required this.selected, + required this.onSelected, + this.visualDensity, + this.singleChoice = false, + }); + + final Widget label; + final bool selected; + final ValueChanged? onSelected; + final VisualDensity? visualDensity; + final bool singleChoice; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + if (singleChoice) { + return ChoiceChip( + label: label, + selected: selected, + onSelected: onSelected, + visualDensity: visualDensity, + ); + } + return FilterChip( + label: label, + selected: selected, + onSelected: onSelected, + visualDensity: visualDensity, + ); + } + final scheme = Theme.of(context).colorScheme; + final foreground = onSelected == null + ? scheme.onSurfaceVariant + : selected + ? scheme.primary + : scheme.onSurface; + return Semantics( + selected: selected, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(24), + color: selected + ? scheme.primary.withValues(alpha: 0.12) + : MornyeTheme.controlFill(context), + disabledColor: MornyeTheme.controlFill(context, enabled: false), + onPressed: onSelected == null ? null : () => onSelected!(!selected), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: foreground, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selected) ...[ + Icon(CupertinoIcons.check_mark, size: 18, color: foreground), + const SizedBox(width: 8), + ], + Flexible(child: label), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/app_content_card.dart b/lib/widgets/app_content_card.dart new file mode 100644 index 00000000..217c723d --- /dev/null +++ b/lib/widgets/app_content_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// One inline surface, matching the metadata groups and dialog controls. +class AppContentCard extends StatelessWidget { + const AppContentCard({ + super.key, + required this.child, + this.color, + this.shape, + this.elevation, + this.clipBehavior = Clip.none, + this.preserveColor = false, + }); + + final Widget child; + final Color? color; + final ShapeBorder? shape; + final double? elevation; + final Clip clipBehavior; + final bool preserveColor; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return Card( + color: color, + shape: shape, + elevation: elevation, + clipBehavior: clipBehavior, + child: child, + ); + } + return Material( + color: preserveColor ? color : MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: child, + ), + ); + } +} diff --git a/lib/widgets/app_search_field.dart b/lib/widgets/app_search_field.dart index 9d7a2a4d..6a23ff14 100644 --- a/lib/widgets/app_search_field.dart +++ b/lib/widgets/app_search_field.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; /// The standard search field used by top-level app surfaces. @@ -12,8 +17,11 @@ class AppSearchField extends StatelessWidget { required this.controller, required this.hintText, required this.clearTooltip, - required this.onChanged, + this.onChanged, required this.onClear, + this.onSubmitted, + this.prefixIcon, + this.suffixIcon, this.focusNode, this.autofocus = false, }); @@ -21,17 +29,23 @@ class AppSearchField extends StatelessWidget { final TextEditingController controller; final String hintText; final String clearTooltip; - final ValueChanged onChanged; + final ValueChanged? onChanged; final VoidCallback onClear; + final ValueChanged? onSubmitted; + final Widget? prefixIcon; + final Widget? suffixIcon; final FocusNode? focusNode; final bool autofocus; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final radius = BorderRadius.circular(context.tokens.radiusSheet); + final mornye = context.isMornye; + final radius = BorderRadius.circular( + mornye ? 28 : context.tokens.radiusSheet, + ); - return ValueListenableBuilder( + final field = ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) => TextField( controller: controller, @@ -40,39 +54,64 @@ class AppSearchField extends StatelessWidget { textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: hintText, - prefixIcon: const Icon(Icons.search), - suffixIcon: value.text.isEmpty - ? null - : IconButton( - tooltip: clearTooltip, - icon: const Icon(Icons.clear), - onPressed: () { - controller.clear(); - onClear(); - }, - ), + prefixIcon: + prefixIcon ?? Icon(mornye ? CupertinoIcons.search : Icons.search), + suffixIcon: + suffixIcon ?? + (value.text.isEmpty + ? null + : IconButton( + tooltip: clearTooltip, + icon: Icon( + mornye + ? CupertinoIcons.clear_circled_solid + : Icons.clear, + ), + onPressed: () { + controller.clear(); + onClear(); + }, + )), border: OutlineInputBorder( borderRadius: radius, - borderSide: BorderSide(color: colorScheme.outlineVariant), + borderSide: mornye + ? BorderSide.none + : BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: radius, - borderSide: BorderSide(color: colorScheme.outlineVariant), + borderSide: mornye + ? BorderSide.none + : BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( borderRadius: radius, - borderSide: BorderSide(color: colorScheme.primary, width: 2), + borderSide: mornye + ? BorderSide.none + : BorderSide(color: colorScheme.primary, width: 2), ), filled: true, - fillColor: settingsGroupColor(context), + fillColor: mornye ? Colors.transparent : settingsGroupColor(context), contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, ), ), onChanged: onChanged, + onSubmitted: onSubmitted, onTapOutside: (_) => FocusScope.of(context).unfocus(), ), ); + if (!mornye) return field; + return Consumer( + builder: (context, ref, child) => MornyeGlass.navigation( + radius: 28, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: child!, + ), + child: field, + ); } } diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index cc411f10..351f1147 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -64,7 +65,9 @@ class AppSliverHeader extends StatelessWidget { tooltip: MaterialLocalizations.of( context, ).backButtonTooltip, - icon: const Icon(Icons.arrow_back), + icon: Icon( + context.isMornye ? Icons.chevron_left : Icons.arrow_back, + ), onPressed: () => Navigator.pop(context), ), ) @@ -116,6 +119,7 @@ class AppSliverHeader extends StatelessWidget { style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, + letterSpacing: context.isMornye ? -0.7 * expandRatio : null, color: colorScheme.onSurface, ), ), diff --git a/lib/widgets/app_snack_bar.dart b/lib/widgets/app_snack_bar.dart new file mode 100644 index 00000000..dfdb1f25 --- /dev/null +++ b/lib/widgets/app_snack_bar.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Transient messages use the same glass as confirmation dialogs in Mornye. +ScaffoldFeatureController showAppSnackBar( + BuildContext context, { + required Widget content, + Duration duration = const Duration(seconds: 4), + SnackBarBehavior? behavior, +}) { + final messenger = ScaffoldMessenger.of(context); + if (!context.isMornye) { + return messenger.showSnackBar( + SnackBar(content: content, duration: duration, behavior: behavior), + ); + } + final theme = Theme.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final snackBar = _MornyeSnackBar( + duration: duration, + reduceMotion: reduceMotion, + content: Theme( + data: theme, + child: MornyeGlassPanel.overlay( + radius: 24, + // Messages have no modal scrim; let more of the page show through. + tintOpacity: 0.60, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: DefaultTextStyle( + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurface, + ), + child: content, + ), + ), + ), + ), + ); + return messenger.showSnackBar(snackBar); +} + +class _MornyeSnackBar extends SnackBar { + const _MornyeSnackBar({ + required super.content, + required super.duration, + required this.reduceMotion, + }) : super( + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.transparent, + elevation: 0, + clipBehavior: Clip.none, + padding: EdgeInsets.zero, + margin: const EdgeInsets.fromLTRB(20, 8, 20, 12), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + ); + + final bool reduceMotion; + + @override + SnackBar withAnimation(Animation newAnimation, {Key? fallbackKey}) { + // Keep the messenger's controller/timer for queuing and dismissal, but + // replace Material's opacity layer with movement so blur samples the page. + return SnackBar( + key: key ?? fallbackKey, + duration: duration, + behavior: behavior, + backgroundColor: backgroundColor, + elevation: elevation, + clipBehavior: clipBehavior, + padding: padding, + margin: margin, + shape: shape, + animation: const AlwaysStoppedAnimation(1), + content: AnimatedBuilder( + animation: newAnimation, + child: content, + builder: (context, child) => Transform.translate( + offset: Offset( + 0, + reduceMotion + ? 0 + : 8 * (1 - Curves.easeOutCubic.transform(newAnimation.value)), + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/app_switch.dart b/lib/widgets/app_switch.dart new file mode 100644 index 00000000..14bb95d6 --- /dev/null +++ b/lib/widgets/app_switch.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// One switch style for settings, extension controls and editing sheets. +/// Mornye keeps its wide oval thumb even when glass or motion is disabled. +class AppSwitch extends StatelessWidget { + const AppSwitch({ + super.key, + required this.value, + required this.onChanged, + this.adaptive = false, + this.semanticLabel, + }); + + final bool value; + final ValueChanged? onChanged; + final bool adaptive; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return adaptive + ? Switch.adaptive(value: value, onChanged: onChanged) + : Switch(value: value, onChanged: onChanged); + } + + return Consumer( + builder: (context, ref, _) { + final scheme = Theme.of(context).colorScheme; + final enabled = onChanged != null; + final useGlass = + enabled && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + final inactive = scheme.brightness == Brightness.dark + ? const Color(0xff39393d) + : const Color(0xff8e8e93); + + final Widget control; + if (useGlass) { + control = Transform.flip( + flipX: Directionality.of(context) == TextDirection.rtl, + child: LiquidGlassSwitch( + value: value, + onChanged: onChanged!, + activeColor: scheme.primary, + inactiveColor: inactive, + ), + ); + } else { + control = Container( + width: 63, + height: 28, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: value ? scheme.primary : inactive, + borderRadius: BorderRadius.circular(14), + ), + child: Align( + alignment: value + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: Container( + width: 37, + height: 24, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } + + return Semantics( + label: semanticLabel, + toggled: value, + enabled: enabled, + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? () => onChanged!(!value) : null, + borderRadius: BorderRadius.circular(22), + // InkWell supplies keyboard activation and an accessible tap + // action; the glass control owns the inner drag gesture. + child: ExcludeSemantics( + child: SizedBox( + width: 71, + height: 44, + child: Center(child: control), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class AppSwitchListTile extends StatelessWidget { + const AppSwitchListTile({ + super.key, + required this.value, + required this.onChanged, + required this.title, + this.subtitle, + this.contentPadding, + this.adaptive = false, + }); + + final bool value; + final ValueChanged? onChanged; + final Widget title; + final Widget? subtitle; + final EdgeInsetsGeometry? contentPadding; + final bool adaptive; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return adaptive + ? SwitchListTile.adaptive( + value: value, + onChanged: onChanged, + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + ) + : SwitchListTile( + value: value, + onChanged: onChanged, + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + ); + } + return MergeSemantics( + child: ListTile( + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + enabled: onChanged != null, + onTap: onChanged == null ? null : () => onChanged!(!value), + trailing: AppSwitch(value: value, onChanged: onChanged), + ), + ); + } +} diff --git a/lib/widgets/audio_analysis_info_card.dart b/lib/widgets/audio_analysis_info_card.dart index 1c87bb83..85f7853a 100644 --- a/lib/widgets/audio_analysis_info_card.dart +++ b/lib/widgets/audio_analysis_info_card.dart @@ -13,7 +13,7 @@ class _AudioInfoCard extends StatelessWidget { final cs = Theme.of(context).colorScheme; final nyquist = data.sampleRate / 2; - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( diff --git a/lib/widgets/audio_analysis_spectrogram.dart b/lib/widgets/audio_analysis_spectrogram.dart index f251b695..636e5468 100644 --- a/lib/widgets/audio_analysis_spectrogram.dart +++ b/lib/widgets/audio_analysis_spectrogram.dart @@ -28,7 +28,8 @@ class _SpectrogramView extends StatelessWidget { final cs = Theme.of(context).colorScheme; const labelColor = Color(0xFFB5B5B5); - return Card( + final card = AppContentCard( + preserveColor: true, color: Colors.black, clipBehavior: Clip.antiAlias, child: Column( @@ -41,7 +42,8 @@ class _SpectrogramView extends StatelessWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - ChoiceChip( + AppChoiceChip( + singleChoice: true, label: Text(context.l10n.audioAnalysisChannels), selected: selectedChannel < 0, onSelected: channelLoading @@ -51,7 +53,8 @@ class _SpectrogramView extends StatelessWidget { ), for (var channel = 0; channel < channels; channel++) ...[ const SizedBox(width: 6), - ChoiceChip( + AppChoiceChip( + singleChoice: true, label: Text('Ch ${channel + 1}'), selected: selectedChannel == channel, onSelected: channelLoading @@ -141,6 +144,11 @@ class _SpectrogramView extends StatelessWidget { ], ), ); + // The plot stays black in both appearances; its controls need matching + // contrast even when the surrounding Mornye page uses the light theme. + return context.isMornye + ? Theme(data: MornyeTheme.build(Brightness.dark), child: card) + : card; } static List _legendColors() { diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 4d86ce84..b516f6ee 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -10,8 +10,11 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -1540,7 +1543,7 @@ class _AudioAnalysisCardState extends State { if (_checkingCache) return const SizedBox.shrink(); if (_unsupportedCodec != null) { - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( @@ -1586,7 +1589,7 @@ class _AudioAnalysisCardState extends State { if (_analyzing) { final isRescan = _data != null || _spectrogramImage != null; - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( @@ -1619,7 +1622,8 @@ class _AudioAnalysisCardState extends State { } if (_error != null) { - return Card( + return AppContentCard( + preserveColor: true, color: cs.errorContainer, child: Padding( padding: const EdgeInsets.all(16), @@ -1649,7 +1653,7 @@ class _AudioAnalysisCardState extends State { } if (_data == null) { - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index 055f6800..db1e337f 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -/// Modern, card-based batch convert sheet shared by the queue and album -/// screens, matching the single-track convert sheet styling. +/// Conversion options shared by the queue, album and single-track sheets. class BatchConvertSheet extends StatefulWidget { final List formats; final String title; @@ -93,7 +97,7 @@ class _BatchConvertSheetState extends State { minChildSize: 0.5, maxChildSize: 0.95, expand: false, - builder: (context, scrollController) => SafeArea( + builder: (context, scrollController) => _sheetSurface( child: SingleChildScrollView( controller: scrollController, padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), @@ -327,13 +331,21 @@ class _BatchConvertSheetState extends State { horizontal: 14, vertical: 12, ), - decoration: BoxDecoration( - color: cs.primaryContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(14), - ), + decoration: context.isMornye + ? null + : BoxDecoration( + color: cs.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(14), + ), child: Row( children: [ - Icon(Icons.verified, size: 18, color: cs.primary), + Icon( + context.isMornye + ? CupertinoIcons.checkmark_seal + : Icons.verified, + size: 18, + color: cs.primary, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -364,7 +376,7 @@ class _BatchConvertSheetState extends State { _card( cs, - child: SwitchListTile( + child: AppSwitchListTile( contentPadding: EdgeInsets.zero, title: Text(context.l10n.trackConvertKeepOriginal), subtitle: Text( @@ -378,7 +390,7 @@ class _BatchConvertSheetState extends State { const SizedBox(height: 8), SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: () => widget.onConvert( _selectedFormat, _selectedBitrate, @@ -421,7 +433,27 @@ class _BatchConvertSheetState extends State { ); } + Widget _sheetSurface({required Widget child}) { + final content = SafeArea(child: child); + return context.isMornye + ? MornyeGlassPanel.overlay(child: content) + : content; + } + Widget _card(ColorScheme cs, {required Widget child}) { + if (context.isMornye) { + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + child, + const SizedBox(height: 20), + const Divider(height: 1), + ], + ), + ); + } return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), @@ -458,6 +490,15 @@ class _BatchConvertSheetState extends State { required bool selected, required VoidCallback onTap, }) { + if (context.isMornye) { + return MornyeFilterChip( + label: label, + selected: selected, + onTap: onTap, + icon: selected ? CupertinoIcons.checkmark : null, + glass: false, + ); + } return Material( color: selected ? cs.primaryContainer : cs.surface, borderRadius: BorderRadius.circular(14), diff --git a/lib/widgets/batch_progress_dialog.dart b/lib/widgets/batch_progress_dialog.dart index 673b17aa..16a550cc 100644 --- a/lib/widgets/batch_progress_dialog.dart +++ b/lib/widgets/batch_progress_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; class _BatchProgress { final int current; @@ -65,7 +66,7 @@ class BatchProgressDialog extends StatefulWidget { _activeNotifier = ValueNotifier(const _BatchProgress()); final notifier = _activeNotifier!; - showDialog( + showAppDialog( context: context, barrierDismissible: false, builder: (_) => BatchProgressDialog._( @@ -117,7 +118,7 @@ class _BatchProgressDialogState extends State { final detail = widget._progressNotifier.value.detail; final progress = widget.total > 0 ? current / widget.total : 0.0; - return AlertDialog( + return AppAlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), content: Column( @@ -176,7 +177,7 @@ class _BatchProgressDialogState extends State { ], ), actions: [ - TextButton( + AppDialogAction( onPressed: widget.onCancel, child: Text(context.l10n.dialogCancel), ), diff --git a/lib/widgets/collection_scaffold.dart b/lib/widgets/collection_scaffold.dart index 4b42dc1e..06532e35 100644 --- a/lib/widgets/collection_scaffold.dart +++ b/lib/widgets/collection_scaffold.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; /// Shared shell for every track-collection screen: album, playlist, local @@ -17,7 +19,7 @@ class CollectionScaffold extends StatefulWidget { required this.onExitSelectionMode, required this.appBar, required this.slivers, - required this.bottomInset, + this.bottomInset, this.selectionBar, this.backgroundColor, }); @@ -32,8 +34,8 @@ class CollectionScaffold extends StatefulWidget { /// Content slivers below the header (track list, error card, footer, ...). final List slivers; - /// Bottom inset needed to clear the shell navigation bar. - final double bottomInset; + /// Overrides the shell inset; otherwise only the trailing spacer watches it. + final double? bottomInset; /// Rebuilt by the caller each frame; shown only while [isSelectionMode] is /// true. Null means the screen has no multi-select. @@ -101,7 +103,10 @@ class _CollectionScaffoldState extends State { body: CustomScrollView( controller: widget.scrollController, slivers: [ - widget.appBar, + if (widget.appBar case final AlbumDetailHeader header) + ...header.buildSlivers(context) + else + widget.appBar, ...widget.slivers, SliverToBoxAdapter( child: SizedBox( @@ -109,7 +114,7 @@ class _CollectionScaffoldState extends State { height: widget.isSelectionMode ? _selectionBarHeight : 32, ), ), - SliverToBoxAdapter(child: SizedBox(height: widget.bottomInset)), + NavBarSliverSpacer(bottomInset: widget.bottomInset), ], ), ), diff --git a/lib/widgets/cross_extension_share_sheet.dart b/lib/widgets/cross_extension_share_sheet.dart index c4139f7c..cb1b57f6 100644 --- a/lib/widgets/cross_extension_share_sheet.dart +++ b/lib/widgets/cross_extension_share_sheet.dart @@ -148,7 +148,7 @@ class _CrossExtensionShareTile extends StatelessWidget { final url = result.url; final hasUrl = result.found && url != null && url.isNotEmpty; - final tile = ListTile( + final tile = AppSheetOption( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( width: 44, diff --git a/lib/widgets/destructive_selection_button.dart b/lib/widgets/destructive_selection_button.dart index 3ee2cd03..f712a61c 100644 --- a/lib/widgets/destructive_selection_button.dart +++ b/lib/widgets/destructive_selection_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; /// Full-width delete action for selection-mode bottom bars: destructive /// (error) styling once at least one item is selected, disabled/neutral @@ -20,7 +21,8 @@ class DestructiveSelectionButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + isDestructive: true, onPressed: count > 0 ? onPressed : null, icon: const Icon(Icons.delete_outline), label: Text( diff --git a/lib/widgets/discard_changes_dialog.dart b/lib/widgets/discard_changes_dialog.dart index bb92cefb..226ad41d 100644 --- a/lib/widgets/discard_changes_dialog.dart +++ b/lib/widgets/discard_changes_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; /// Shared discard-unsaved-changes confirmation dialog used by priority / @@ -7,17 +8,20 @@ Future showDiscardChangesDialog( BuildContext context, { String? content, }) async { - final result = await showDialog( + final result = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogDiscardChanges), content: Text(content ?? context.l10n.dialogUnsavedChanges), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.dialogDiscard), ), diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index d1551ef2..889ead6e 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -1,10 +1,15 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; @@ -39,19 +44,41 @@ class DownloadServicePicker extends ConsumerStatefulWidget { showModalBottomSheet( context: context, useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, + backgroundColor: context.isMornye + ? Colors.transparent + : colorScheme.surfaceContainerHigh, + elevation: context.isMornye ? 0 : null, + barrierColor: context.isMornye + ? Colors.black.withValues(alpha: 0.26) + : null, isScrollControlled: true, - showDragHandle: true, + showDragHandle: !context.isMornye, constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.88, ), - builder: (context) => DownloadServicePicker( - trackName: trackName, - artistName: artistName, - coverUrl: coverUrl, - onSelect: onSelect, - recommendedService: recommendedService, - ), + builder: (context) { + final picker = DownloadServicePicker( + trackName: trackName, + artistName: artistName, + coverUrl: coverUrl, + onSelect: onSelect, + recommendedService: recommendedService, + ); + return context.isMornye + ? MornyeGlassPanel.overlay( + tintOpacity: colorScheme.brightness == Brightness.dark + ? 0.68 + : 0.75, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), + Flexible(child: picker), + ], + ), + ) + : picker; + }, ); } } @@ -120,6 +147,18 @@ class _DownloadServicePickerState extends ConsumerState { final downloadExtensions = _downloadExtensions(); final hasProviders = downloadExtensions.isNotEmpty; final qualityOptions = _getQualityOptions(downloadExtensions); + final qualityRows = [ + for (final quality in qualityOptions) + _QualityOption( + title: _localizedQualityLabel(context, quality), + subtitle: _localizedQualityDescription(context, quality), + icon: _getQualityIcon(quality.id), + onTap: () { + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); + }, + ), + ]; return SafeArea( top: false, @@ -160,9 +199,8 @@ class _DownloadServicePickerState extends ConsumerState { children: [ for (final ext in downloadExtensions) _ServiceChip( - label: widget.recommendedService == ext.id - ? '${ext.displayName} (Recommended)' - : ext.displayName, + label: ext.displayName, + isRecommended: widget.recommendedService == ext.id, healthStatus: ext.hasServiceHealth ? extensionState.healthStatuses[ext.id]?.status : null, @@ -179,7 +217,12 @@ class _DownloadServicePickerState extends ConsumerState { ), if (hasProviders) ...[ Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + padding: EdgeInsets.fromLTRB( + 24, + context.isMornye ? 24 : 16, + 24, + context.isMornye ? 12 : 8, + ), child: Text( context.l10n.downloadSelectQuality, style: Theme.of(context).textTheme.titleMedium?.copyWith( @@ -187,16 +230,32 @@ class _DownloadServicePickerState extends ConsumerState { ), ), ), - for (final quality in qualityOptions) - _QualityOption( - title: _localizedQualityLabel(context, quality), - subtitle: _localizedQualityDescription(context, quality), - icon: _getQualityIcon(quality.id), - onTap: () { - Navigator.pop(context); - widget.onSelect(quality.id, _selectedService); - }, - ), + if (context.isMornye) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(24), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < qualityRows.length; i++) ...[ + if (i > 0) + Divider( + height: 0.5, + indent: 52, + endIndent: 16, + color: MornyeTheme.metadataDividerColor(context), + ), + qualityRows[i], + ], + ], + ), + ), + ) + else + ...qualityRows, ], const SizedBox(height: 16), @@ -273,6 +332,53 @@ class _QualityOption extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.all(16), + onPressed: onTap, + child: Row( + children: [ + SizedBox( + width: 24, + child: Icon( + mornyeIconFor(icon), + color: colorScheme.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ); + } return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( @@ -298,6 +404,7 @@ class _QualityOption extends StatelessWidget { class _ServiceChip extends StatelessWidget { final String label; final bool isSelected; + final bool isRecommended; final VoidCallback? onTap; final String? iconPath; final String? healthStatus; @@ -306,6 +413,7 @@ class _ServiceChip extends StatelessWidget { required this.label, required this.isSelected, required this.onTap, + this.isRecommended = false, this.iconPath, this.healthStatus, }); @@ -313,6 +421,76 @@ class _ServiceChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final accessibleLabel = isRecommended ? '$label (Recommended)' : label; + if (context.isMornye) { + final foreground = isSelected + ? colorScheme.primary + : colorScheme.onSurface; + return Semantics( + button: true, + selected: isSelected, + hint: isRecommended ? 'Recommended' : null, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(24), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.14) + : MornyeTheme.controlFill(context), + onPressed: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + Icon(CupertinoIcons.checkmark, color: foreground, size: 18), + const SizedBox(width: 8), + ], + if (iconPath != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath!), + width: 18, + height: 18, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Icon( + CupertinoIcons.square_grid_2x2, + size: 18, + color: foreground, + ), + ), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + if (isRecommended) ...[ + const SizedBox(width: 6), + Tooltip( + message: accessibleLabel, + excludeFromSemantics: true, + child: Icon( + CupertinoIcons.star_fill, + size: 12, + color: foreground, + ), + ), + ], + if (healthStatus != null) ...[ + const SizedBox(width: 8), + _ServiceHealthDot(status: healthStatus!), + ], + ], + ), + ), + ); + } return GestureDetector( onTap: onTap, child: AnimatedContainer( @@ -356,7 +534,7 @@ class _ServiceChip extends StatelessWidget { const SizedBox(width: 6), ], Text( - label, + accessibleLabel, style: TextStyle( fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected diff --git a/lib/widgets/duplicate_review_sheet.dart b/lib/widgets/duplicate_review_sheet.dart index 61b95971..05aa69a7 100644 --- a/lib/widgets/duplicate_review_sheet.dart +++ b/lib/widgets/duplicate_review_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; @@ -92,18 +93,19 @@ class _DuplicateReviewSheetState extends ConsumerState { } Future _confirmDelete(String message) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - useRootNavigator: true, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.dialogDeleteSelectedTitle), content: Text(message), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, diff --git a/lib/widgets/extension_repo_card.dart b/lib/widgets/extension_repo_card.dart new file mode 100644 index 00000000..6fbab2c3 --- /dev/null +++ b/lib/widgets/extension_repo_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Repo cards share the Mornye glass material, including their detail pages. +class ExtensionRepoCard extends ConsumerWidget { + const ExtensionRepoCard({ + super.key, + required this.child, + this.color, + this.radius = 20, + this.margin = const EdgeInsets.all(4), + }); + + final Widget child; + final Color? color; + final double radius; + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!context.isMornye) { + return Card( + elevation: 0, + color: color, + margin: margin, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + child: child, + ); + } + return Padding( + padding: margin, + child: MornyeGlass.navigation( + radius: 24, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: Material(color: Colors.transparent, child: child), + ), + ); + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index e206bee6..046511dd 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -1,15 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; class MiniPlayer extends ConsumerStatefulWidget { - const MiniPlayer({super.key}); + const MiniPlayer({super.key, this.compact = false, this.bottomPadding = 8}); + + final bool compact; + final double bottomPadding; @override ConsumerState createState() => _MiniPlayerState(); @@ -34,8 +41,10 @@ class _MiniPlayerState extends ConsumerState { final controller = ref.read(musicPlayerControllerProvider); final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final compact = mornye && widget.compact; - return Dismissible( + final player = Dismissible( key: ValueKey('mini-player-${mediaItem.id}'), direction: DismissDirection.horizontal, onDismissed: (_) { @@ -45,14 +54,18 @@ class _MiniPlayerState extends ConsumerState { child: DecoratedBox( position: DecorationPosition.foreground, decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), + border: mornye + ? null + : Border( + top: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), ), child: Material( - color: settingsGroupColor(context).withValues(alpha: 0.72), + color: mornye + ? Colors.transparent + : settingsGroupColor(context).withValues(alpha: 0.72), child: InkWell( onTap: () { Navigator.of( @@ -63,14 +76,15 @@ class _MiniPlayerState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _MiniPlayerProgress( - duration: mediaItem.duration ?? Duration.zero, - backgroundColor: colorScheme.surfaceContainerHighest, - ), + if (!mornye) + _MiniPlayerProgress( + duration: mediaItem.duration ?? Duration.zero, + backgroundColor: colorScheme.surfaceContainerHighest, + ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + padding: EdgeInsets.symmetric( + horizontal: compact ? 8 : 12, + vertical: mornye ? 0 : 8, ), child: Row( children: [ @@ -79,8 +93,8 @@ class _MiniPlayerState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( - width: 44, - height: 44, + width: mornye ? 38 : 44, + height: mornye ? 38 : 44, child: PlayerArtwork( artUri: mediaItem.artUri?.toString(), colorScheme: colorScheme, @@ -90,7 +104,7 @@ class _MiniPlayerState extends ConsumerState { ), ), ), - const SizedBox(width: 12), + SizedBox(width: compact ? 8 : 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -112,7 +126,16 @@ class _MiniPlayerState extends ConsumerState { mediaItem.artist ?? '', style: Theme.of(context).textTheme.bodySmall ?.copyWith( - color: colorScheme.onSurfaceVariant, + color: mornye + ? Color.lerp( + colorScheme.onSurfaceVariant, + colorScheme.onSurface, + colorScheme.brightness == + Brightness.dark + ? 0.7 + : 0.6, + ) + : colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -121,6 +144,7 @@ class _MiniPlayerState extends ConsumerState { ), ), IconButton( + color: mornye ? colorScheme.onSurface : null, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, @@ -131,16 +155,30 @@ class _MiniPlayerState extends ConsumerState { strokeWidth: 2.5, ), ) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + : Icon( + mornye + ? (isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill) + : (isPlaying + ? Icons.pause + : Icons.play_arrow), + ), onPressed: isLoading ? null : () => controller.togglePlayPause(isPlaying), ), - IconButton( - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), + if (!compact) + IconButton( + color: mornye ? colorScheme.onSurface : null, + tooltip: context.l10n.nowPlayingNextTrack, + icon: Icon( + mornye + ? CupertinoIcons.forward_fill + : Icons.skip_next, + ), + onPressed: controller.next, + ), ], ), ), @@ -150,6 +188,19 @@ class _MiniPlayerState extends ConsumerState { ), ), ); + return mornye + ? Padding( + padding: EdgeInsets.only(bottom: widget.bottomPadding), + child: MornyeGlass.navigation( + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: player, + ), + ) + : player; } } diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart new file mode 100644 index 00000000..32746785 --- /dev/null +++ b/lib/widgets/mornye_artist_header.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; + +/// Share the same artwork-derived dark surface while loading and after the +/// artist metadata arrives, including when the app itself uses light mode. +class MornyeArtistSurface extends StatefulWidget { + const MornyeArtistSurface({ + super.key, + required this.imageSource, + required this.child, + this.neutralActions = false, + }); + + final String? imageSource; + final Widget child; + + /// Album and playlist actions use white controls over the artwork tint. + final bool neutralActions; + + @override + State createState() => _MornyeArtistSurfaceState(); +} + +class _MornyeArtistSurfaceState extends State { + @override + void dispose() { + ShellNavigationService.clearChromeBrightness(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final darkTheme = MornyeTheme.build(Brightness.dark); + return Theme( + data: darkTheme.copyWith( + colorScheme: darkTheme.colorScheme.copyWith(primary: Colors.grey), + ), + child: CoverPaletteBuilder( + imageSource: widget.imageSource, + builder: (context, palette) { + // Retain the artwork's hue with a muted, slightly lifted surface. + // Large backgrounds need less saturation than the cover's accent. + final dominant = HSLColor.fromColor(palette.primary); + final surface = dominant + .withSaturation((dominant.saturation * 0.45).clamp(0.0, 0.22)) + .withLightness(0.34) + .toColor(); + final route = ModalRoute.of(context); + if (route != null) { + ShellNavigationService.setChromeBrightness( + owner: this, + route: route, + brightness: Brightness.dark, + surface: surface, + ); + } + return Theme( + data: darkTheme.copyWith( + scaffoldBackgroundColor: surface, + colorScheme: darkTheme.colorScheme.copyWith( + primary: widget.neutralActions ? Colors.white : null, + onPrimary: widget.neutralActions ? surface : null, + onSurfaceVariant: widget.neutralActions ? Colors.white70 : null, + surface: surface, + surfaceContainer: Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + surface, + ), + surfaceContainerHigh: Color.alphaBlend( + Colors.white.withValues(alpha: 0.12), + surface, + ), + ), + ), + child: widget.child, + ); + }, + ), + ); + } +} + +/// Portrait and centered identity from Mornye's MobileArtistView. Actions stay +/// supplied by the screen so remote artists retain download/favorite behavior. +class MornyeArtistHeader extends StatelessWidget { + const MornyeArtistHeader({ + super.key, + required this.name, + required this.artwork, + required this.actions, + required this.showTitle, + this.listeners, + }); + + final String name; + final Widget artwork; + final List actions; + final bool showTitle; + final String? listeners; + + @override + Widget build(BuildContext context) => + SliverMainAxisGroup(slivers: buildSlivers(context)); + + List buildSlivers(BuildContext context) { + final surface = Theme.of(context).colorScheme.surface; + return [ + // Padding changes while the shell folds. Keep that dependency on the + // toolbar so callers do not rebuild the artist's entire discography. + Builder( + builder: (context) => SliverAppBar( + pinned: true, + expandedHeight: 342 - MediaQuery.paddingOf(context).top, + backgroundColor: surface, + surfaceTintColor: Colors.transparent, + leadingWidth: 64, + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + title: AnimatedOpacity( + opacity: showTitle ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + artwork, + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.52, 1], + colors: [ + Colors.black.withValues(alpha: 0.15), + surface.withValues(alpha: 0), + surface, + ], + ), + ), + ), + ], + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), + child: Column( + children: [ + Text( + name, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + if (listeners != null) ...[ + const SizedBox(height: 7), + Text( + listeners!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + if (actions.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 24, + children: actions, + ), + ], + ], + ), + ), + ), + ]; + } +} diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart new file mode 100644 index 00000000..752e7c07 --- /dev/null +++ b/lib/widgets/mornye_bottom_bar.dart @@ -0,0 +1,207 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Only deliberate vertical drags change the chrome. Horizontal artwork rows, +/// programmatic scrolling and edge bounce must not minimize the navigation. +class MornyeChromeController extends ValueNotifier { + MornyeChromeController() : super(false); + + double _distance = 0; + + void expand() { + _distance = 0; + value = false; + } + + bool handleScroll(ScrollNotification notification) { + if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification is ScrollStartNotification || + notification is ScrollEndNotification) { + _distance = 0; + } + if (notification is! ScrollUpdateNotification || + notification.dragDetails == null) { + return false; + } + final metrics = notification.metrics; + if (metrics.pixels <= metrics.minScrollExtent + 12) { + expand(); + return false; + } + if (metrics.outOfRange) return false; + final delta = notification.scrollDelta ?? 0; + if (delta == 0) return false; + if (_distance.sign != delta.sign) _distance = 0; + _distance += delta; + if (_distance >= 28) { + value = true; + _distance = 0; + } else if (_distance <= -18) { + expand(); + } + return false; + } +} + +/// A single mini-player survives the transition, preserving its artwork Hero, +/// playback controls and swipe-to-dismiss state as the tabs fold away. +class MornyeBottomBar extends ConsumerWidget { + const MornyeBottomBar({ + super.key, + required this.collapsed, + required this.destinations, + required this.selectedIndex, + required this.onSelected, + required this.onExpand, + required this.onSearch, + required this.blurEnabled, + }); + + final bool collapsed; + final List destinations; + final int selectedIndex; + final ValueChanged onSelected; + final VoidCallback onExpand; + final VoidCallback onSearch; + final bool blurEnabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasPlayer = ref.watch( + currentMediaItemProvider.select((item) => item.value != null), + ); + // Animated glass tabs already reserve 8px above their visible capsule. + final tabGap = + blurEnabled && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context) + ? 0.0 + : 8.0; + // These contents do not depend on animation progress. Retain their widget + // instances so folding only updates size/opacity wrappers each frame. + Widget sideContent({ + required Widget icon, + required String tooltip, + required VoidCallback onPressed, + }) => RepaintBoundary( + child: MornyeGlass.navigation( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + radius: 26, + child: SizedBox.square( + dimension: 52, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: tooltip, + color: Theme.of(context).colorScheme.primary, + icon: icon, + onPressed: onPressed, + ), + ), + ), + ), + ); + final expandButton = sideContent( + icon: destinations[selectedIndex].icon, + tooltip: context.l10n.mornyeShowTabs, + onPressed: onExpand, + ); + final searchButton = sideContent( + icon: const Icon(CupertinoIcons.search), + tooltip: context.l10n.mornyeSearch, + onPressed: onSearch, + ); + final player = MiniPlayer(compact: collapsed, bottomPadding: 0); + final tabs = TickerMode( + enabled: !collapsed, + child: RepaintBoundary( + child: MornyeTabBar( + destinations: destinations, + selectedIndex: selectedIndex, + onSelected: onSelected, + blurEnabled: blurEnabled, + ), + ), + ); + return TweenAnimationBuilder( + tween: Tween(end: collapsed ? 1 : 0), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380), + curve: Curves.easeInOutCubic, + builder: (context, amount, _) { + Widget sideButton({ + required Widget child, + required Alignment alignment, + }) => ClipRect( + child: Align( + alignment: alignment, + widthFactor: amount, + child: SizedBox( + width: 60, + height: 48 + 4 * amount, + child: Align( + alignment: alignment, + child: IgnorePointer( + ignoring: !collapsed, + child: ExcludeSemantics( + excluding: !collapsed, + child: Opacity(opacity: amount, child: child), + ), + ), + ), + ), + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasPlayer || amount > 0) + Padding( + padding: EdgeInsets.only( + bottom: tabGap + (8 - tabGap) * amount, + ), + child: Row( + children: [ + sideButton( + child: expandButton, + alignment: Alignment.centerLeft, + ), + Expanded(child: player), + sideButton( + child: searchButton, + alignment: Alignment.centerRight, + ), + ], + ), + ), + ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: 1 - amount, + child: IgnorePointer( + ignoring: collapsed, + child: ExcludeSemantics( + excluding: collapsed, + child: Opacity(opacity: 1 - amount, child: tabs), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart new file mode 100644 index 00000000..a1f49845 --- /dev/null +++ b/lib/widgets/mornye_chrome.dart @@ -0,0 +1,613 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +class MornyeSegmentedControl extends ConsumerWidget { + const MornyeSegmentedControl({ + super.key, + required this.labels, + required this.selectedIndex, + required this.onChanged, + }); + + final List labels; + final int selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final blur = + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + final animate = !MediaQuery.disableAnimationsOf(context); + final height = MediaQuery.textScalerOf(context).scale(15) + 36; + final rtl = Directionality.of(context) == TextDirection.rtl; + final visualLabels = rtl ? labels.reversed.toList() : labels; + int logicalIndex(int index) => rtl ? labels.length - 1 - index : index; + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + height: height + 16, + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: Directionality( + textDirection: TextDirection.ltr, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + top: 8, + height: height, + child: _MornyeGlassSurface( + blurEnabled: blur, + radius: height / 2, + child: const SizedBox.expand(), + ), + ), + LiquidGlassTabBar.withImpeller( + width: constraints.maxWidth, + height: height, + margin: const EdgeInsets.only(bottom: 8), + itemPadding: 4, + selectedIndex: logicalIndex(selectedIndex), + onChanged: (index) => onChanged(logicalIndex(index)), + style: const LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: 32, + borderWidth: 0, + lightIntensity: 0, + ), + appearance: LiquidGlassAppearance(), + refraction: LiquidGlassRefraction( + distortion: 0, + chromaticAberration: 0, + ), + ), + pillStyle: LiquidGlassTabPillStyle( + mode: blur && animate + ? LiquidGlassPillMode.impellerOnly + : LiquidGlassPillMode.none, + animated: animate, + ), + itemStyle: LiquidGlassTabItemStyle( + selectedColor: scheme.onSurface, + unselectedColor: scheme.onSurface, + iconSize: 0, + iconLabelGap: 0, + labelFontSize: 15, + ), + items: [ + for (final label in visualLabels) + LiquidGlassTabBarItem( + label: label, + iconBuilder: (_, _) => const SizedBox.shrink(), + labelBuilder: (context, state) => Text( + label, + textDirection: rtl + ? TextDirection.rtl + : TextDirection.ltr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + fontWeight: state.selected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The bounded, translucent chrome used by Mornye's tab accessory. Blur is +/// limited to the control itself and follows the app's device performance gate. +class MornyeGlassPanel extends ConsumerWidget { + const MornyeGlassPanel({ + super.key, + required this.child, + this.radius = 32, + this.strongTint = false, + this.tintOpacity, + }); + + /// Shared glass material for confirmation dialogs and floating sheets. + const MornyeGlassPanel.overlay({ + super.key, + required this.child, + this.radius = 32, + this.tintOpacity = 0.78, + }) : strongTint = false; + + final Widget child; + final double radius; + final bool strongTint; + final double? tintOpacity; + + @override + Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation( + radius: radius, + strongTint: strongTint, + tintOpacity: tintOpacity, + blurEnabled: + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)), + child: Material( + color: strongTint + ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.60) + : Colors.transparent, + child: child, + ), + ); +} + +class MornyeGlass extends StatelessWidget { + const MornyeGlass({ + super.key, + required this.child, + required this.blurEnabled, + this.radius = 32, + this.strongTint = false, + this.tintOpacity, + this.tintColor, + }) : _useLens = true, + firstInGroup = true, + lastInGroup = true; + + /// Shares the navigation bar's tint, blur and visible outer rim. + const MornyeGlass.navigation({ + super.key, + required this.child, + required this.blurEnabled, + this.radius = 32, + this.firstInGroup = true, + this.lastInGroup = true, + this.strongTint = false, + this.tintOpacity, + this.tintColor, + }) : _useLens = false; + + final Widget child; + final bool blurEnabled; + final double radius; + final bool _useLens; + final bool firstInGroup; + final bool lastInGroup; + + /// Keeps floating controls readable over arbitrary album artwork. + final bool strongTint; + + /// Uses a single tint instead of layered highlights for translucent surfaces. + final double? tintOpacity; + final Color? tintColor; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final dark = scheme.brightness == Brightness.dark; + final useGlass = + _useLens && blurEnabled && !MediaQuery.highContrastOf(context); + return _MornyeGlassSurface( + blurEnabled: blurEnabled, + radius: radius, + firstInGroup: firstInGroup, + lastInGroup: lastInGroup, + strongTint: strongTint, + tintOpacity: tintOpacity, + tintColor: tintColor, + child: useGlass + ? LiquidGlassLens( + style: LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: radius, + borderWidth: dark && tintOpacity == null ? 0 : 0.6, + lightIntensity: dark && tintOpacity == null ? 0 : 0.18, + ), + appearance: LiquidGlassAppearance( + color: tintOpacity != null + ? Colors.transparent + : dark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.28) + : Colors.white.withValues( + alpha: strongTint ? 0.20 : 0.55, + ), + // The surface already blurs the backdrop. Refracting that + // frosted result needs no second Gaussian blur pass. + blur: const LiquidGlassBlur(), + ), + refraction: const LiquidGlassRefraction( + distortion: 0.02, + distortionWidth: 8, + chromaticAberration: 0, + ), + ), + child: child, + ) + : child, + ); + } +} + +/// Keep the capsule legible independently of the shader renderer. The liquid +/// lens adds refraction above this frosted base, never above bare page text. +class _MornyeGlassSurface extends StatelessWidget { + static final _backdropBlur = ImageFilter.blur(sigmaX: 18, sigmaY: 18); + + const _MornyeGlassSurface({ + required this.child, + required this.blurEnabled, + this.radius = 32, + this.firstInGroup = true, + this.lastInGroup = true, + this.strongTint = false, + this.tintOpacity, + this.tintColor, + }); + + final Widget child; + final bool blurEnabled; + final double radius; + final bool firstInGroup; + final bool lastInGroup; + final bool strongTint; + final double? tintOpacity; + final Color? tintColor; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final useBlur = blurEnabled && !MediaQuery.highContrastOf(context); + final dark = scheme.brightness == Brightness.dark; + final shape = BorderRadius.vertical( + top: firstInGroup ? Radius.circular(radius) : Radius.zero, + bottom: lastInGroup ? Radius.circular(radius) : Radius.zero, + ); + final rim = BorderSide( + color: dark + ? Colors.white.withValues(alpha: 0.16) + : Colors.black.withValues(alpha: 0.17), + width: 0.75, + ); + final border = Border( + top: firstInGroup ? rim : BorderSide.none, + bottom: lastInGroup ? rim : BorderSide.none, + left: rim, + right: rim, + ); + final surface = DecoratedBox( + decoration: BoxDecoration( + color: (tintColor ?? scheme.surfaceContainerHigh).withValues( + alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1, + ), + gradient: useBlur && !dark && tintOpacity == null + ? LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.35, 0.75, 1], + colors: [ + Colors.white.withValues(alpha: 0.90), + Colors.white.withValues(alpha: strongTint ? 0.80 : 0.78), + scheme.surfaceContainerHigh.withValues( + alpha: strongTint ? 0.76 : 0.72, + ), + Colors.white.withValues(alpha: 0.85), + ], + ) + : null, + borderRadius: shape, + border: dark ? border : null, + ), + child: child, + ); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: shape, + boxShadow: firstInGroup && lastInGroup + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06), + blurRadius: dark ? 18 : 10, + offset: Offset(0, dark ? 4 : 2), + ), + ] + : null, + ), + child: DecoratedBox( + // Paint the light outline above the lens so its pale tint cannot wash + // the edge out on an all-white page. Dark chrome keeps its quiet rim. + position: DecorationPosition.foreground, + decoration: BoxDecoration( + borderRadius: shape, + border: dark ? null : border, + ), + child: ClipRRect( + borderRadius: shape, + child: useBlur + ? BackdropFilter(filter: _backdropBlur, child: surface) + : surface, + ), + ), + ); + } +} + +/// A searchable category uses the same material as the navigation capsule. +class MornyeFilterChip extends ConsumerWidget { + const MornyeFilterChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.icon, + this.blurEnabled = true, + this.glass = true, + this.tonal = true, + }); + + final String label; + final bool selected; + final VoidCallback? onTap; + final IconData? icon; + final bool blurEnabled; + final bool glass; + final bool tonal; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + final content = Semantics( + button: true, + selected: selected, + enabled: onTap != null, + child: Material( + color: !glass && !tonal + ? Colors.transparent + : selected + ? scheme.primary.withValues(alpha: 0.12) + : glass + ? Colors.transparent + : MornyeTheme.controlFill(context, enabled: onTap != null), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: scheme.primary), + const SizedBox(width: 8), + ], + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: selected ? scheme.primary : scheme.onSurface, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ), + ); + if (!glass) { + return ClipRRect(borderRadius: BorderRadius.circular(24), child: content); + } + return MornyeGlass.navigation( + radius: 24, + blurEnabled: + blurEnabled && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)), + child: content, + ); + } +} + +class MornyeTabBar extends StatelessWidget { + const MornyeTabBar({ + super.key, + required this.destinations, + required this.selectedIndex, + required this.onSelected, + required this.blurEnabled, + }); + + final List destinations; + final int selectedIndex; + final ValueChanged onSelected; + final bool blurEnabled; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final inactiveIconColor = Color.lerp( + scheme.onSurfaceVariant, + scheme.onSurface, + scheme.brightness == Brightness.dark ? 0.5 : 0.4, + ); + if (blurEnabled && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context)) { + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + // Leave room above and below for the travelling pill to lift and + // stretch; its shader must not be clipped to the resting capsule. + height: 80, + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + bottom: 8, + height: 64, + child: _MornyeGlassSurface( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + child: const SizedBox.expand(), + ), + ), + LiquidGlassTabBar.withImpeller( + width: constraints.maxWidth, + height: 64, + margin: const EdgeInsets.only(bottom: 8), + selectedIndex: selectedIndex, + onChanged: onSelected, + style: LiquidGlassTabBar.defaultStyle.copyWith( + // The frosted base owns the subtle outline. Disable the + // package's default specular rim around the whole capsule. + shape: const LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: 32, + borderWidth: 0, + lightIntensity: 0, + ), + // The sibling surface already supplies tint and blur. Keep + // the lens clear so the moving pill can refract the icons. + appearance: const LiquidGlassAppearance(), + refraction: const LiquidGlassRefraction( + distortion: 0, + chromaticAberration: 0, + ), + ), + itemStyle: LiquidGlassTabItemStyle( + selectedColor: scheme.primary, + unselectedColor: scheme.onSurface, + iconSize: 25, + labelFontSize: 11, + ), + pillStyle: const LiquidGlassTabPillStyle( + mode: LiquidGlassPillMode.impellerOnly, + animated: true, + // Keep the moving refractive pill, without stacking the + // package's second magnifier lens beneath it. + magnifierPill: LiquidGlassTabMagnifierPillStyle( + enabled: false, + ), + ), + items: [ + for (final destination in destinations) + LiquidGlassTabBarItem( + label: destination.label, + iconBuilder: (context, icon) => IconTheme( + data: IconThemeData( + size: icon.size, + color: icon.selected + ? scheme.primary + : inactiveIconColor, + ), + child: destination.icon, + ), + labelBuilder: (context, label) => Text( + destination.label, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontSize: label.textStyle.fontSize, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + return MornyeGlass.navigation( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + child: Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + for (var index = 0; index < destinations.length; index++) + Expanded( + child: Semantics( + selected: index == selectedIndex, + button: true, + label: destinations[index].label, + excludeSemantics: true, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(28), + onTap: () => onSelected(index), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + constraints: const BoxConstraints(minHeight: 54), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + decoration: BoxDecoration( + color: index == selectedIndex + ? scheme.onSurface.withValues(alpha: 0.09) + : Colors.transparent, + borderRadius: BorderRadius.circular(28), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData( + size: 25, + color: index == selectedIndex + ? scheme.primary + : inactiveIconColor, + ), + // Keep tab selection quiet, as in Mornye. Badges + // stay live without the Material bounce/spin. + child: destinations[index].icon, + ), + const SizedBox(height: 2), + Text( + destinations[index].label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart new file mode 100644 index 00000000..22866d74 --- /dev/null +++ b/lib/widgets/mornye_context_menu.dart @@ -0,0 +1,265 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Bounds of the tapped control or row, in the root overlay's coordinates. +Rect? mornyeMenuAnchor(BuildContext context) { + final box = context.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + if (box.size.height > MediaQuery.sizeOf(context).height * 0.65) return null; + return box.localToGlobal(Offset.zero) & box.size; +} + +Future showMornyeContextMenu({ + required BuildContext context, + required WidgetBuilder builder, + Rect? anchor, +}) { + final navigator = Navigator.of(context, rootNavigator: true); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + final target = anchor ?? mornyeMenuAnchor(context); + return navigator.push( + RawDialogRoute( + barrierColor: Colors.black.withValues(alpha: 0.12), + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + transitionDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160), + // Opacity layers isolate a BackdropFilter from the underlying artwork. + transitionBuilder: (_, _, _, child) => child, + pageBuilder: (context, animation, _) { + final media = MediaQuery.of(context); + return Semantics( + scopesRoute: true, + namesRoute: true, + label: MaterialLocalizations.of(context).popupMenuLabel, + explicitChildNodes: true, + child: CustomSingleChildLayout( + delegate: _MenuLayout( + anchor: target, + padding: media.padding.copyWith( + bottom: math.max(media.padding.bottom, media.viewInsets.bottom), + ), + ), + child: ScaleTransition( + scale: animation.drive(Tween(begin: 0.96, end: 1)), + child: themes.wrap(Builder(builder: builder)), + ), + ), + ); + }, + ), + ); +} + +class _MenuLayout extends SingleChildLayoutDelegate { + const _MenuLayout({required this.anchor, required this.padding}); + + final Rect? anchor; + final EdgeInsets padding; + static const double _margin = 16; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final width = math.min( + 320.0, + math.max(0.0, constraints.maxWidth - padding.horizontal - _margin * 2), + ); + return BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: math.max( + 0, + constraints.maxHeight - padding.vertical - _margin * 2, + ), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final left = padding.left + _margin; + final top = padding.top + _margin; + final right = math.max( + left, + size.width - padding.right - _margin - childSize.width, + ); + final bottom = math.max( + top, + size.height - padding.bottom - _margin - childSize.height, + ); + final x = anchor == null + ? (size.width - childSize.width) / 2 + : anchor!.right - childSize.width; + var y = anchor == null ? size.height * 0.18 : anchor!.bottom + 8; + if (anchor != null && y > bottom) { + y = anchor!.top - childSize.height - 8; + if (y < top) y = bottom; + } + return Offset(x.clamp(left, right), y.clamp(top, bottom)); + } + + @override + bool shouldRelayout(_MenuLayout oldDelegate) => + anchor != oldDelegate.anchor || padding != oldDelegate.padding; +} + +class MornyeMenuAction { + const MornyeMenuAction({ + required this.icon, + required this.label, + required this.onPressed, + this.subtitle, + this.selected = false, + this.destructive = false, + }); + + final IconData icon; + final String label; + final String? subtitle; + final VoidCallback? onPressed; + final bool selected; + final bool destructive; +} + +/// One frosted popover, with compact shortcuts and separated action groups. +class MornyeContextMenu extends StatelessWidget { + const MornyeContextMenu({ + super.key, + this.quickActions = const [], + required this.groups, + this.inheritSurface = false, + }); + + final List quickActions; + final List> groups; + final bool inheritSurface; + + @override + Widget build(BuildContext context) { + final theme = inheritSurface + ? Theme.of(context) + : MornyeTheme.build(Theme.of(context).brightness); + final visibleGroups = groups.where((group) => group.isNotEmpty).toList(); + final shortcuts = quickActions.isEmpty + ? null + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final action in quickActions) + Expanded(child: _action(theme, action, compact: true)), + ], + ), + ); + final actions = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < visibleGroups.length; i++) ...[ + if (i > 0 || quickActions.isNotEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: i == 0 ? 0 : 8), + child: const Divider( + height: 0.5, + thickness: 0.5, + indent: 20, + endIndent: 20, + ), + ), + for (final action in visibleGroups[i]) _action(theme, action), + ], + ], + ), + ); + return Theme( + data: theme, + child: MornyeGlassPanel.overlay( + radius: 28, + child: LayoutBuilder( + builder: (context, constraints) { + // Let the entire menu scroll when pinning the shortcuts would + // leave too little room for a row in landscape or large text. + if (constraints.maxHeight < 360 || + MediaQuery.textScalerOf(context).scale(17) > 28) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [?shortcuts, actions], + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?shortcuts, + Flexible(child: SingleChildScrollView(child: actions)), + ], + ); + }, + ), + ), + ); + } + + Widget _action( + ThemeData theme, + MornyeMenuAction action, { + bool compact = false, + }) { + final color = action.onPressed == null + ? theme.colorScheme.onSurfaceVariant + : action.destructive || action.selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface; + final icon = Icon(action.icon, size: compact ? 23 : 21, color: color); + final label = Text( + action.label, + textAlign: compact ? TextAlign.center : TextAlign.start, + style: theme.textTheme.bodyLarge?.copyWith( + color: color, + fontSize: compact ? 13 : 17, + fontWeight: compact ? FontWeight.w600 : FontWeight.w400, + ), + ); + return Semantics( + selected: action.selected, + child: CupertinoButton( + padding: compact + ? const EdgeInsets.symmetric(horizontal: 4, vertical: 10) + : const EdgeInsets.symmetric(horizontal: 20, vertical: 13), + onPressed: action.onPressed, + child: compact + ? Column( + mainAxisSize: MainAxisSize.min, + children: [icon, const SizedBox(height: 7), label], + ) + : Row( + children: [ + icon, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + label, + if (action.subtitle != null) ...[ + const SizedBox(height: 3), + Text( + action.subtitle!, + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart new file mode 100644 index 00000000..214b653b --- /dev/null +++ b/lib/widgets/mornye_landscape_player.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Landscape keeps the cover in place while the right pane switches between +/// lyrics, the queue, and controls revealed by a tap. +class MornyeLandscapePlayer extends StatefulWidget { + const MornyeLandscapePlayer({ + super.key, + required this.artwork, + required this.header, + required this.lyrics, + required this.queue, + required this.controls, + required this.volume, + }); + + final Widget artwork; + final Widget header; + final Widget lyrics; + final Widget queue; + final Widget controls; + final Widget volume; + + @override + State createState() => _MornyeLandscapePlayerState(); +} + +class _MornyeLandscapePlayerState extends State { + Timer? _hideTimer; + bool _controlsVisible = false; + bool _queueVisible = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.accessibleNavigationOf(context)) { + _controlsVisible = true; + _hideTimer?.cancel(); + } + } + + void _scheduleHide() { + _hideTimer?.cancel(); + if (!_controlsVisible || MediaQuery.accessibleNavigationOf(context)) return; + _hideTimer = Timer(const Duration(seconds: 4), () { + if (!mounted) return; + if (ModalRoute.of(context)?.isCurrent == false) { + _scheduleHide(); + return; + } + setState(() => _controlsVisible = false); + }); + } + + void _reveal() { + if (!_controlsVisible) setState(() => _controlsVisible = true); + _scheduleHide(); + } + + void _showPanel({required bool queue}) { + _hideTimer?.cancel(); + setState(() { + _queueVisible = queue; + _controlsVisible = false; + }); + } + + @override + void dispose() { + _hideTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Listener( + onPointerDown: (_) => _hideTimer?.cancel(), + onPointerUp: (_) => _scheduleHide(), + onPointerCancel: (_) => _scheduleHide(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _reveal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded(child: widget.artwork), + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: widget.header, + ), + const SizedBox(height: 8), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + IgnorePointer( + child: ExcludeSemantics(child: child), + ), + ?current, + ], + ), + child: _controlsVisible + ? SingleChildScrollView( + key: const ValueKey('landscape-controls'), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Column( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + widget.controls, + widget.volume, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: context + .l10n + .nowPlayingTabLyrics, + icon: const Icon( + CupertinoIcons.quote_bubble, + ), + onPressed: () => + _showPanel(queue: false), + ), + IconButton( + tooltip: + context.l10n.nowPlayingUpNext, + icon: const Icon( + CupertinoIcons.list_bullet, + ), + onPressed: () => + _showPanel(queue: true), + ), + ], + ), + ), + ], + ), + ), + ) + : KeyedSubtree( + key: ValueKey(_queueVisible), + child: _queueVisible + ? widget.queue + : widget.lyrics, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/widgets/mornye_metadata_row.dart b/lib/widgets/mornye_metadata_row.dart new file mode 100644 index 00000000..05546f30 --- /dev/null +++ b/lib/widgets/mornye_metadata_row.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// Mornye TrackInfoView's vertically stacked label and value. +class MornyeMetadataRow extends StatelessWidget { + const MornyeMetadataRow({ + super.key, + required this.label, + required this.value, + this.showDivider = true, + this.trailing, + this.onTap, + this.onLongPress, + }); + + final String label; + final String value; + final bool showDivider; + final Widget? trailing; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final content = Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ?trailing, + ], + ), + const SizedBox(height: 5), + Text(value, style: theme.textTheme.bodyLarge), + if (showDivider) ...[ + const SizedBox(height: 7), + const Divider(height: 0.5), + ], + ], + ), + ); + if (onTap == null && onLongPress == null) return content; + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: content, + ); + } +} diff --git a/lib/widgets/mornye_playback_button.dart b/lib/widgets/mornye_playback_button.dart new file mode 100644 index 00000000..33b86b63 --- /dev/null +++ b/lib/widgets/mornye_playback_button.dart @@ -0,0 +1,143 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; + +/// The press response and advancing skip glyphs used by Mornye's transport. +class MornyePlaybackButton extends StatefulWidget { + const MornyePlaybackButton({ + super.key, + required this.icon, + required this.tooltip, + required this.color, + required this.onPressed, + this.iconSize = 40, + this.padding = const EdgeInsets.all(8), + this.loading = false, + }); + + final IconData icon; + final String tooltip; + final Color color; + final VoidCallback? onPressed; + final double iconSize; + final EdgeInsetsGeometry padding; + final bool loading; + + @override + State createState() => _MornyePlaybackButtonState(); +} + +class _MornyePlaybackButtonState extends State + with SingleTickerProviderStateMixin { + final _states = WidgetStatesController(); + late final _skip = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + bool get _isSkip => + widget.icon == CupertinoIcons.forward_fill || + widget.icon == CupertinoIcons.backward_fill; + + @override + void dispose() { + _states.dispose(); + _skip.dispose(); + super.dispose(); + } + + Widget _skipGlyph(bool reduceMotion) => AnimatedBuilder( + animation: _skip, + builder: (context, _) { + if (!_skip.isAnimating || reduceMotion) return Icon(widget.icon); + final progress = Curves.easeInOut.transform(_skip.value); + final size = widget.iconSize; + Widget triangle(double position, double scale) => Positioned( + left: size * (position - 0.26), + top: 0, + width: size * 0.52, + height: size, + child: Transform.scale( + scale: scale, + child: const FittedBox( + fit: BoxFit.fill, + child: Icon(CupertinoIcons.play_fill), + ), + ), + ); + return Transform.flip( + flipX: widget.icon == CupertinoIcons.backward_fill, + child: Stack( + clipBehavior: Clip.none, + children: [ + triangle(0.25, progress), + triangle(0.25 + progress * 0.5, 1), + triangle(0.75, 1 - progress), + ], + ), + ); + }, + ); + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final motion = Duration(milliseconds: reduceMotion ? 120 : 180); + final enabled = widget.onPressed != null && !widget.loading; + return IconButton( + statesController: _states, + tooltip: widget.tooltip, + color: widget.color, + iconSize: widget.iconSize, + padding: widget.padding, + style: ButtonStyle( + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.focused) || + states.contains(WidgetState.hovered) + ? widget.color.withValues(alpha: 0.08) + : Colors.transparent; + }), + ), + onPressed: enabled + ? () { + if (_isSkip && !reduceMotion) _skip.forward(from: 0); + widget.onPressed!(); + } + : null, + icon: ValueListenableBuilder>( + valueListenable: _states, + builder: (context, states, _) { + final pressed = states.contains(WidgetState.pressed); + return AnimatedScale( + scale: pressed && !reduceMotion ? 0.95 : 1, + duration: motion, + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: pressed ? 0.72 : 1, + duration: motion, + child: SizedBox.square( + dimension: widget.iconSize, + child: AnimatedSwitcher( + duration: Duration(milliseconds: reduceMotion ? 120 : 200), + child: widget.loading + ? const Center( + child: SizedBox.square( + dimension: 32, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ) + : KeyedSubtree( + key: ValueKey(widget.icon), + child: _isSkip + ? _skipGlyph(reduceMotion) + : Icon(widget.icon), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/mornye_playback_time.dart b/lib/widgets/mornye_playback_time.dart new file mode 100644 index 00000000..8c4e10b9 --- /dev/null +++ b/lib/widgets/mornye_playback_time.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +/// Rolls only the changed digits, keeping the timeline labels steady in width. +class MornyePlaybackTime extends StatefulWidget { + const MornyePlaybackTime({ + super.key, + required this.seconds, + required this.style, + this.remaining = false, + }); + + final int seconds; + final TextStyle? style; + final bool remaining; + + @override + State createState() => _MornyePlaybackTimeState(); +} + +class _MornyePlaybackTimeState extends State { + bool _increasing = true; + + @override + void didUpdateWidget(MornyePlaybackTime oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.seconds != oldWidget.seconds) { + _increasing = widget.seconds > oldWidget.seconds; + } + } + + @override + Widget build(BuildContext context) { + final clock = formatClock(widget.seconds); + final label = '${widget.remaining ? '-' : ''}$clock'; + final style = (widget.style ?? const TextStyle()).copyWith( + fontFeatures: [ + ...?widget.style?.fontFeatures, + const FontFeature.tabularFigures(), + ], + ); + if (MediaQuery.disableAnimationsOf(context)) { + return Text(label, style: style); + } + + return Semantics( + label: label, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + textDirection: TextDirection.ltr, + children: [ + if (widget.remaining) Text('-', style: style), + for (var index = 0; index < clock.length; index++) + ClipRect( + // Anchor digit slots from the seconds end when minutes grow. + key: ValueKey(clock.length - index), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final entering = child.key == ValueKey(clock[index]); + final direction = _increasing ? 0.7 : -0.7; + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, entering ? direction : -direction), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: Text( + clock[index], + key: ValueKey(clock[index]), + style: style, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/mornye_player_actions_sheet.dart b/lib/widgets/mornye_player_actions_sheet.dart new file mode 100644 index 00000000..6cb42c95 --- /dev/null +++ b/lib/widgets/mornye_player_actions_sheet.dart @@ -0,0 +1,126 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; + +/// Navigation for the title/artist, separate from playback and file actions. +class MornyePlayerNavigationMenu extends StatelessWidget { + const MornyePlayerNavigationMenu({super.key, required this.mediaItem}); + + final MediaItem mediaItem; + + @override + Widget build(BuildContext context) { + final theme = MornyeTheme.build(Brightness.dark); + final art = mediaItem.artUri; + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith(primary: Colors.grey), + ), + child: CoverPaletteBuilder( + imageSource: art?.scheme == 'file' + ? art!.toFilePath() + : art?.toString(), + builder: (context, palette) { + final dominant = HSLColor.fromColor(palette.primary); + final surface = dominant + .withSaturation(dominant.saturation.clamp(0.0, 0.28)) + .withLightness(0.28) + .toColor(); + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + surfaceContainerHigh: surface, + ), + ), + child: MornyeContextMenu( + inheritSurface: true, + groups: [ + [ + if ((mediaItem.artist ?? '').trim().isNotEmpty) + MornyeMenuAction( + icon: CupertinoIcons.mic, + label: context.l10n.mornyeGoToArtist, + subtitle: mediaItem.artist, + onPressed: () => Navigator.of(context).pop('artist'), + ), + if ((mediaItem.album ?? '').trim().isNotEmpty) + MornyeMenuAction( + icon: CupertinoIcons.square_stack, + label: context.l10n.homeGoToAlbum, + subtitle: mediaItem.album, + onPressed: () => Navigator.of(context).pop('album'), + ), + ], + ], + ), + ); + }, + ), + ); + } +} + +/// A floating menu that inherits the player's dark appearance. +class MornyePlayerActionsSheet extends StatelessWidget { + const MornyePlayerActionsSheet({ + super.key, + required this.mediaItem, + this.sleepTimerSubtitle, + }); + + final MediaItem mediaItem; + final String? sleepTimerSubtitle; + + @override + Widget build(BuildContext context) => MornyeContextMenu( + groups: [ + [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + _action( + context, + 'album', + context.l10n.homeGoToAlbum, + CupertinoIcons.square_stack, + ), + _action( + context, + 'details', + context.l10n.nowPlayingDetails, + CupertinoIcons.info, + ), + ], + [ + _action( + context, + 'sleepTimer', + context.l10n.nowPlayingSleepTimer, + CupertinoIcons.moon_zzz, + subtitle: sleepTimerSubtitle, + ), + _action( + context, + 'external', + context.l10n.nowPlayingOpenInExternalPlayer, + CupertinoIcons.arrow_up_right_square, + ), + ], + ], + ); + + MornyeMenuAction _action( + BuildContext context, + String value, + String label, + IconData icon, { + String? subtitle, + }) => MornyeMenuAction( + icon: icon, + label: label, + subtitle: subtitle, + onPressed: () => Navigator.of(context).pop(value), + ); +} diff --git a/lib/widgets/mornye_player_artwork.dart b/lib/widgets/mornye_player_artwork.dart new file mode 100644 index 00000000..8504c0cf --- /dev/null +++ b/lib/widgets/mornye_player_artwork.dart @@ -0,0 +1,35 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; + +class MornyePlayerArtwork extends StatelessWidget { + const MornyePlayerArtwork({ + super.key, + required this.mediaItem, + this.videoUrl, + }); + + final MediaItem mediaItem; + final String? videoUrl; + + @override + Widget build(BuildContext context) { + final fallback = PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: Theme.of(context).colorScheme, + cacheWidth: + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context)) + .round(), + ); + if (MediaQuery.disableAnimationsOf(context)) return fallback; + return videoUrl == null + ? fallback + : MotionHeaderBanner( + key: ValueKey(videoUrl), + videoUrl: videoUrl!, + fallback: fallback, + ); + } +} diff --git a/lib/widgets/mornye_player_background.dart b/lib/widgets/mornye_player_background.dart new file mode 100644 index 00000000..7bfe23c6 --- /dev/null +++ b/lib/widgets/mornye_player_background.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; + +/// Opaque cover-derived surface, with full-bleed artwork on the player page +/// and a muted two-tone gradient behind lyrics and the queue. +class MornyePlayerBackground extends StatelessWidget { + const MornyePlayerBackground({ + super.key, + required this.artUri, + this.artwork, + this.squareArtwork = true, + }); + + final Uri? artUri; + final Widget? artwork; + final bool squareArtwork; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final source = artUri?.scheme == 'file' + ? artUri!.toFilePath() + : artUri?.toString(); + + return Theme( + // Missing or unreadable covers use neutral gray, not the app's accent. + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + brightness: Brightness.dark, + primary: const Color(0xff808080), + ), + ), + child: CoverPaletteBuilder( + imageSource: source, + builder: (context, scheme) { + final dominant = HSLColor.fromColor(scheme.primary); + final muted = dominant.withSaturation( + dominant.saturation.clamp(0.0, 0.28), + ); + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380); + return AnimatedContainer( + duration: motion, + curve: Curves.easeInOutCubic, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + muted.withLightness(artwork == null ? 0.28 : 0.10).toColor(), + muted.withLightness(artwork == null ? 0.15 : 0.10).toColor(), + ], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: squareArtwork + ? MediaQuery.sizeOf(context).width + : MediaQuery.sizeOf(context).height * 0.66, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.58, 1], + colors: [Colors.white, Colors.white, Colors.transparent], + ).createShader(bounds), + child: HeroMode( + // The compact header owns the route hero while the + // full cover is fading out behind the lyrics. + enabled: artwork != null, + child: AnimatedSwitcher( + duration: motion, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + HeroMode(enabled: false, child: child), + ?current, + ], + ), + child: artwork == null + ? null + : KeyedSubtree( + key: const ValueKey('full-player-artwork'), + child: artwork!, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/mornye_player_details_sheet.dart b/lib/widgets/mornye_player_details_sheet.dart new file mode 100644 index 00000000..b68da365 --- /dev/null +++ b/lib/widgets/mornye_player_details_sheet.dart @@ -0,0 +1,152 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; + +/// Uses Mornye's TrackInfoView layout: artwork header, then selectable values +/// below small labels, separated by thin dividers on one glass surface. +class MornyePlayerDetailsSheet extends ConsumerWidget { + const MornyePlayerDetailsSheet({ + super.key, + required this.rows, + required this.scrollController, + this.mediaItem, + }); + + final List<(String, String)> rows; + final ScrollController scrollController; + final MediaItem? mediaItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final item = mediaItem; + final blur = + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + return MornyeGlass.navigation( + blurEnabled: blur, + child: SafeArea( + top: false, + child: Column( + children: [ + const AppSheetHandle(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.nowPlayingDetails, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: 12), + onPressed: () => Navigator.of(context).pop(), + child: Text( + context.l10n.dialogDone, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + Expanded( + child: SelectionArea( + child: CustomScrollView( + controller: scrollController, + slivers: [ + if (item != null) + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 18), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 70, + child: PlayerArtwork( + artUri: item.artUri?.toString(), + colorScheme: theme.colorScheme, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 5), + Text( + item.artist ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge + ?.copyWith( + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + if (rows.isEmpty) + SliverPadding( + padding: const EdgeInsets.all(24), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.nowPlayingNoMetadata, + style: theme.textTheme.bodyMedium, + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + sliver: SliverList.builder( + itemCount: rows.length, + itemBuilder: (context, index) { + final (label, value) = rows[index]; + return MornyeMetadataRow( + label: label, + value: value, + showDivider: index < rows.length - 1, + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mornye_player_favorite_button.dart b/lib/widgets/mornye_player_favorite_button.dart new file mode 100644 index 00000000..09205358 --- /dev/null +++ b/lib/widgets/mornye_player_favorite_button.dart @@ -0,0 +1,75 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; + +class MornyePlayerFavoriteButton extends ConsumerStatefulWidget { + const MornyePlayerFavoriteButton({ + super.key, + required this.mediaItem, + this.compact = false, + }); + + final MediaItem mediaItem; + final bool compact; + + @override + ConsumerState createState() => + _MornyePlayerFavoriteButtonState(); +} + +class _MornyePlayerFavoriteButtonState + extends ConsumerState { + bool _saving = false; + + Future _toggle() async { + if (_saving) return; + setState(() => _saving = true); + try { + final provider = playerCollectionTrackProvider(widget.mediaItem); + if (ref.read(provider).hasError) ref.invalidate(provider); + final track = await ref.read(provider.future); + if (!mounted) return; + await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarError(context.friendlyError(error)), + ), + ), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final track = ref + .watch(playerCollectionTrackProvider(widget.mediaItem)) + .value; + final loved = ref.watch( + libraryCollectionsProvider.select( + (state) => track != null && state.isLoved(track), + ), + ); + return Semantics( + toggled: loved, + child: MornyePlaybackButton( + icon: loved ? CupertinoIcons.star_fill : CupertinoIcons.star, + tooltip: loved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + color: Theme.of(context).colorScheme.onSurface, + iconSize: widget.compact ? 20 : 24, + onPressed: _saving ? null : _toggle, + ), + ); + } +} diff --git a/lib/widgets/mornye_player_queue.dart b/lib/widgets/mornye_player_queue.dart new file mode 100644 index 00000000..a5cc7cfd --- /dev/null +++ b/lib/widgets/mornye_player_queue.dart @@ -0,0 +1,232 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; + +/// Upcoming tracks share the player's artwork backdrop and transport controls. +class MornyePlayerQueue extends ConsumerWidget { + const MornyePlayerQueue({ + super.key, + required this.colorScheme, + required this.onShuffleLibrary, + }); + + final ColorScheme colorScheme; + final VoidCallback onShuffleLibrary; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queue = ref.watch(playQueueProvider).value ?? const []; + final currentId = ref.watch( + currentMediaItemProvider.select((item) => item.value?.id), + ); + final playback = ref.watch( + playbackStateProvider.select( + (state) => ( + index: state.value?.queueIndex, + shuffle: state.value?.shuffleMode == AudioServiceShuffleMode.all, + repeat: state.value?.repeatMode ?? AudioServiceRepeatMode.none, + ), + ), + ); + final reportedIndex = playback.index; + final currentIndex = + reportedIndex != null && + reportedIndex >= 0 && + reportedIndex < queue.length && + queue[reportedIndex].id == currentId + ? reportedIndex + : queue.indexWhere((item) => item.id == currentId); + final start = currentIndex + 1; + final count = queue.length - start; + final controller = ref.read(musicPlayerControllerProvider); + final type = Theme.of(context).textTheme; + final repeatLabel = switch (playback.repeat) { + AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, + AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, + _ => context.l10n.nowPlayingRepeatAll, + }; + + Widget modeButton({ + required IconData icon, + required String label, + required bool selected, + required VoidCallback onPressed, + }) => Expanded( + child: Semantics( + selected: selected, + child: Tooltip( + message: label, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.white.withValues( + alpha: selected ? 0.3 : 0.12, + ), + foregroundColor: Colors.white, + minimumSize: const Size(0, 44), + shape: const StadiumBorder(), + ), + onPressed: onPressed, + child: Icon(icon, semanticLabel: label, size: 24), + ), + ), + ), + ); + + return CustomScrollView( + key: const PageStorageKey('mornye-player-queue'), + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 28), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + Row( + children: [ + modeButton( + icon: CupertinoIcons.shuffle, + label: playback.shuffle + ? context.l10n.nowPlayingShuffleOn + : context.l10n.nowPlayingPlayInOrder, + selected: playback.shuffle, + onPressed: () => controller.setShuffle(!playback.shuffle), + ), + const SizedBox(width: 12), + modeButton( + icon: playback.repeat == AudioServiceRepeatMode.one + ? CupertinoIcons.repeat_1 + : CupertinoIcons.repeat, + label: repeatLabel, + selected: playback.repeat != AudioServiceRepeatMode.none, + onPressed: () => + controller.setRepeatMode(switch (playback.repeat) { + AudioServiceRepeatMode.none => + AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => + AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + context.l10n.mornyeContinuePlaying, + style: type.titleLarge?.copyWith(color: Colors.white), + ), + ), + Builder( + builder: (buttonContext) => IconButton( + tooltip: MaterialLocalizations.of( + context, + ).moreButtonTooltip, + icon: const Icon( + CupertinoIcons.ellipsis, + color: Colors.white, + ), + onPressed: () => showMornyeContextMenu( + context: buttonContext, + builder: (menuContext) => MornyeContextMenu( + groups: [ + [ + MornyeMenuAction( + icon: CupertinoIcons.shuffle, + label: context.l10n.nowPlayingShuffleLibrary, + onPressed: () { + Navigator.pop(menuContext); + onShuffleLibrary(); + }, + ), + ], + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + if (count == 0) + SliverPadding( + padding: const EdgeInsets.all(28), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.nowPlayingQueueEmpty, + style: type.bodyMedium?.copyWith(color: Colors.white70), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + sliver: SliverReorderableList( + itemCount: count, + onReorderItem: (oldIndex, newIndex) => + controller.moveQueueItem(start + oldIndex, start + newIndex), + proxyDecorator: (child, index, animation) => Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + child: child, + ), + itemBuilder: (context, index) { + final item = queue[start + index]; + return ListTile( + key: ValueKey('${item.id}_${start + index}'), + contentPadding: const EdgeInsets.only(left: 12), + minVerticalPadding: 6, + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: SizedBox.square( + dimension: 40, + child: PlayerArtwork( + artUri: item.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: 120, + iconSize: 22, + ), + ), + ), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.bodyLarge?.copyWith(color: Colors.white), + ), + subtitle: Text( + item.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.bodySmall?.copyWith(color: Colors.white70), + ), + trailing: ReorderableDragStartListener( + index: index, + child: const ColoredBox( + color: Colors.transparent, + child: SizedBox.square( + dimension: 44, + child: Icon( + CupertinoIcons.line_horizontal_3, + color: Colors.white38, + ), + ), + ), + ), + onTap: () => controller.jumpTo(start + index), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/mornye_player_slider.dart b/lib/widgets/mornye_player_slider.dart new file mode 100644 index 00000000..6098b052 --- /dev/null +++ b/lib/widgets/mornye_player_slider.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +/// A continuous player track with no thumb in either gesture state. +class MornyePlayerSlider extends StatelessWidget { + const MornyePlayerSlider({ + super.key, + required this.value, + required this.onChanged, + this.max = 1, + this.activeColor = Colors.white, + this.inactiveColor = const Color(0x2EFFFFFF), + this.onChangeStart, + this.onChangeEnd, + }); + + final double value; + final double max; + final Color activeColor; + final Color inactiveColor; + final ValueChanged onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + + @override + Widget build(BuildContext context) => SizedBox( + height: 44, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 6, + trackGap: 0, + trackShape: const RoundedRectSliderTrackShape(), + thumbShape: SliderComponentShape.noThumb, + overlayShape: SliderComponentShape.noOverlay, + showValueIndicator: ShowValueIndicator.never, + ), + child: Slider( + padding: const EdgeInsets.symmetric(horizontal: 8), + value: value, + max: max, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + ), + ), + ); +} diff --git a/lib/widgets/mornye_volume_control.dart b/lib/widgets/mornye_volume_control.dart new file mode 100644 index 00000000..fa2b0c5f --- /dev/null +++ b/lib/widgets/mornye_volume_control.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:volume_controller/volume_controller.dart'; + +/// A single shared subscription for the visible player, including hardware +/// volume-button changes. This controls system volume, not ReplayGain gain. +final systemVolumeProvider = StreamProvider.autoDispose((ref) { + final events = StreamController(); + final volume = VolumeController.instance; + final subscription = volume.addListener(events.add); + subscription.onError((Object error, StackTrace stack) { + events.addError(error, stack); + }); + ref.onDispose(() { + unawaited(subscription.cancel()); + volume.removeListener(); + unawaited(events.close()); + }); + return events.stream; +}); + +final systemVolumeWriterProvider = Provider Function(double)>(( + ref, +) { + return (value) { + final volume = VolumeController.instance; + volume.showSystemUI = false; + return volume.setVolume(value.clamp(0, 1)); + }; +}); + +class MornyeVolumeControl extends ConsumerStatefulWidget { + const MornyeVolumeControl({super.key}); + + @override + ConsumerState createState() => + _MornyeVolumeControlState(); +} + +class _MornyeVolumeControlState extends ConsumerState { + double? _preview; + double? _pendingVolume; + bool _dragging = false; + bool _writing = false; + + void _setVolume(double value) { + setState(() => _preview = value); + _pendingVolume = value; + if (!_writing) unawaited(_flushVolume()); + } + + Future _flushVolume() async { + _writing = true; + final write = ref.read(systemVolumeWriterProvider); + try { + // Send changes during the gesture. If the platform is still processing + // a write, keep only the newest value instead of queuing stale positions. + while (mounted && _pendingVolume != null) { + final value = _pendingVolume!; + _pendingVolume = null; + try { + await write(value); + } catch (error) { + AppLogger('PlayerVolume').w('Could not set system volume: $error'); + } + } + } finally { + _writing = false; + if (mounted && !_dragging) setState(() => _preview = null); + } + } + + @override + Widget build(BuildContext context) { + final volume = ref.watch(systemVolumeProvider).value; + final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0); + String percentage(double volume) => '${(volume * 100).round()}%'; + return Semantics( + label: context.l10n.nowPlayingVolume, + slider: true, + enabled: volume != null, + excludeSemantics: true, + value: volume == null ? null : percentage(value), + increasedValue: volume == null + ? null + : percentage((value + 0.05).clamp(0, 1)), + decreasedValue: volume == null + ? null + : percentage((value - 0.05).clamp(0, 1)), + onIncrease: volume == null + ? null + : () => _setVolume((value + 0.05).clamp(0, 1)), + onDecrease: volume == null + ? null + : () => _setVolume((value - 0.05).clamp(0, 1)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Row( + children: [ + const Icon( + CupertinoIcons.speaker_fill, + size: 16, + color: Colors.white54, + ), + const SizedBox(width: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => IgnorePointer( + ignoring: volume == null, + child: Opacity( + opacity: volume == null ? 0.4 : 1, + child: MornyePlayerSlider( + value: value, + activeColor: Colors.white70, + inactiveColor: Colors.white12, + onChangeStart: (_) => _dragging = true, + onChanged: _setVolume, + onChangeEnd: (value) { + _dragging = false; + _setVolume(value); + }, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + const Icon( + CupertinoIcons.speaker_3_fill, + size: 16, + color: Colors.white54, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 3d4b60f4..38290d97 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -97,7 +97,9 @@ class _MotionHeaderBannerState extends State final controller = VideoPlayerController.networkUrl( Uri.parse(url), - formatHint: VideoFormat.hls, + formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8') + ? VideoFormat.hls + : null, videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); _controller = controller; diff --git a/lib/widgets/open_on_platform_sheet.dart b/lib/widgets/open_on_platform_sheet.dart index 5af332db..2c6c7606 100644 --- a/lib/widgets/open_on_platform_sheet.dart +++ b/lib/widgets/open_on_platform_sheet.dart @@ -140,7 +140,7 @@ class _OpenOnPlatformSheetState extends State { SettingsGroup( children: [ for (final entry in entries) - ListTile( + AppSheetOption( title: Text(_displayName(entry.key)), trailing: Icon( Icons.open_in_new, diff --git a/lib/widgets/playback_seek_slider.dart b/lib/widgets/playback_seek_slider.dart index b871bf14..ebbe7da3 100644 --- a/lib/widgets/playback_seek_slider.dart +++ b/lib/widgets/playback_seek_slider.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; /// Previews scrubbing locally and seeks once when the gesture ends. class PlaybackSeekSlider extends StatefulWidget { @@ -37,6 +39,43 @@ class _PlaybackSeekSliderState extends State { final durationMs = widget.duration.inMilliseconds; final enabled = durationMs > 0; final maxMs = enabled ? durationMs.toDouble() : 1.0; + if (context.isMornye) { + final currentMs = (_previewMs ?? widget.position.inMilliseconds) + .clamp(0, maxMs) + .toDouble(); + String percentage(double milliseconds) => + '${(milliseconds / maxMs * 100).clamp(0, 100).round()}%'; + return LayoutBuilder( + builder: (context, constraints) => Semantics( + slider: true, + enabled: enabled, + excludeSemantics: true, + value: percentage(currentMs), + increasedValue: enabled + ? percentage((currentMs + 5000).clamp(0, maxMs).toDouble()) + : null, + decreasedValue: enabled + ? percentage((currentMs - 5000).clamp(0, maxMs).toDouble()) + : null, + onIncrease: enabled + ? () => _commit((currentMs + 5000).clamp(0, maxMs).toDouble()) + : null, + onDecrease: enabled + ? () => _commit((currentMs - 5000).clamp(0, maxMs).toDouble()) + : null, + child: IgnorePointer( + ignoring: !enabled, + child: MornyePlayerSlider( + value: enabled ? currentMs : 0, + max: maxMs, + onChangeStart: (_) => _gestureGeneration++, + onChanged: (value) => setState(() => _previewMs = value), + onChangeEnd: _commit, + ), + ), + ), + ); + } return Slider( value: enabled ? (_previewMs ?? widget.position.inMilliseconds.toDouble()).clamp( diff --git a/lib/widgets/player_artwork.dart b/lib/widgets/player_artwork.dart index 78c29ceb..1ac64141 100644 --- a/lib/widgets/player_artwork.dart +++ b/lib/widgets/player_artwork.dart @@ -2,9 +2,11 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; import 'package:spotiflac_android/services/cover_cache_manager.dart'; class PlayerArtwork extends StatelessWidget { + /// An HTTP(S) URL, file URI, or absolute local artwork path. final String? artUri; final ColorScheme colorScheme; final int? cacheWidth; @@ -44,8 +46,10 @@ class PlayerArtwork extends StatelessWidget { errorWidget: (_, _, _) => placeholder, ); } - if (uri.startsWith('file://')) { - final path = Uri.parse(uri).toFilePath(); + if (uri.startsWith('file://') || p.isAbsolute(uri)) { + final path = uri.startsWith('file://') + ? Uri.parse(uri).toFilePath() + : uri; return Image.file( File(path), fit: BoxFit.cover, diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index ef7a44a0..1a237cee 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -1,11 +1,17 @@ import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; Future showAddTrackToPlaylistSheet( BuildContext context, @@ -28,13 +34,17 @@ Future showAddTracksToPlaylistSheet( await showModalBottomSheet( context: context, useRootNavigator: true, - showDragHandle: true, + showDragHandle: !context.isMornye, + backgroundColor: context.isMornye ? Colors.transparent : null, isScrollControlled: true, builder: (sheetContext) { - return _PlaylistPickerSheetContent( + final content = _PlaylistPickerSheetContent( tracks: tracks, playlistNamePrefill: playlistNamePrefill, ); + return sheetContext.isMornye + ? MornyeGlassPanel(tintOpacity: 0.78, child: content) + : content; }, ); } @@ -111,8 +121,7 @@ class _PlaylistPickerSheetContentState final track = widget.tracks.first; subtitle = '${track.name} • ${track.artistName}'; } else { - subtitle = - '${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}'; + subtitle = context.l10n.tracksCount(widget.tracks.length); } final resolvedPlaylists = playlistSummariesValue.asData?.value ?? const []; @@ -124,18 +133,59 @@ class _PlaylistPickerSheetContentState final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds); final hasNewSelections = idsToAdd.isNotEmpty; + void onDone() { + if (hasNewSelections) { + _handleDone(resolvedPlaylists); + } else { + Navigator.of(context).pop(); + } + } + return SafeArea( + top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - leading: const Icon(Icons.playlist_add), - title: Text(context.l10n.collectionAddToPlaylist), - subtitle: Text(subtitle), - ), + if (context.isMornye) ...[ + const AppSheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.collectionAddToPlaylist, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ] else + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(context.l10n.collectionAddToPlaylist), + subtitle: Text(subtitle), + ), const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add_circle_outline), + _PlaylistPickerRow( + leading: Icon( + context.isMornye + ? CupertinoIcons.add_circled + : Icons.add_circle_outline, + color: Theme.of(context).colorScheme.primary, + ), title: Text(context.l10n.collectionCreatePlaylist), onTap: () async { final name = await _promptPlaylistName( @@ -147,6 +197,7 @@ class _PlaylistPickerSheetContentState } final playlistId = await notifier.createPlaylist(name.trim()); await notifier.addTracksToPlaylist(playlistId, widget.tracks); + if (!mounted) return; setState(() { _committedPlaylistIds.add(playlistId); _selectedPlaylistIds.remove(playlistId); @@ -188,10 +239,10 @@ class _PlaylistPickerSheetContentState _selectedPlaylistIds.contains(playlist.id) || isAlreadyIn; - return ListTile( + return _PlaylistPickerRow( leading: _PlaylistPickerThumbnail( playlist: playlist, - isSelected: isSelected, + isSelected: !context.isMornye && isSelected, ), title: Text(playlist.name), subtitle: Text( @@ -200,6 +251,7 @@ class _PlaylistPickerSheetContentState ), ), enabled: !isAlreadyIn, + selected: isSelected, onTap: !isAlreadyIn ? () { setState(() { @@ -241,16 +293,16 @@ class _PlaylistPickerSheetContentState padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, - child: FilledButton( - onPressed: () { - if (hasNewSelections) { - _handleDone(resolvedPlaylists); - } else { - Navigator.of(context).pop(); - } - }, - child: Text(context.l10n.dialogDone), - ), + child: context.isMornye + ? AppActionButton( + onPressed: onDone, + icon: const Icon(CupertinoIcons.checkmark), + label: Text(context.l10n.dialogDone), + ) + : FilledButton( + onPressed: onDone, + child: Text(context.l10n.dialogDone), + ), ), ), ], @@ -266,10 +318,10 @@ Future _promptPlaylistName( final controller = TextEditingController(text: playlistNamePrefill); final formKey = GlobalKey(); - final result = await showDialog( + final result = await showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.collectionCreatePlaylist), content: Form( key: formKey, @@ -294,11 +346,13 @@ Future _promptPlaylistName( ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () { if (formKey.currentState?.validate() != true) return; Navigator.of(dialogContext).pop(controller.text.trim()); @@ -313,6 +367,92 @@ Future _promptPlaylistName( return result; } +class _PlaylistPickerRow extends StatelessWidget { + const _PlaylistPickerRow({ + required this.leading, + required this.title, + this.subtitle, + this.enabled = true, + this.selected, + this.onTap, + }); + + final Widget leading; + final Widget title; + final Widget? subtitle; + final bool enabled; + final bool? selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return ListTile( + leading: leading, + title: title, + subtitle: subtitle, + enabled: enabled, + onTap: onTap, + ); + } + final theme = Theme.of(context); + return Semantics( + selected: selected, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + onPressed: enabled ? onTap : null, + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: Row( + children: [ + leading, + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: theme.textTheme.bodyLarge!, + child: title, + ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + DefaultTextStyle( + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + child: subtitle!, + ), + ], + ], + ), + ), + if (selected != null) ...[ + const SizedBox(width: 14), + Icon( + selected! + ? CupertinoIcons.checkmark_circle_fill + : CupertinoIcons.circle, + size: 24, + color: selected! + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), + ), + ), + const Divider(height: 0.5, thickness: 0.5, indent: 20, endIndent: 20), + ], + ), + ); + } +} + class _PlaylistPickerThumbnail extends StatelessWidget { final PlaylistPickerSummary playlist; final bool isSelected; @@ -383,7 +523,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget { cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(), cacheHeight: (size * MediaQuery.devicePixelRatioOf(context)).round(), filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + errorBuilder: (_, _, _) => _iconFallback(context, colorScheme, size), ); } @@ -394,14 +534,18 @@ class _PlaylistPickerThumbnail extends StatelessWidget { width: size, height: size, networkCacheWidth: (size * 2).toInt(), - placeholder: (_) => _iconFallback(colorScheme, size), + placeholder: (_) => _iconFallback(context, colorScheme, size), ); } - return _iconFallback(colorScheme, size); + return _iconFallback(context, colorScheme, size); } - Widget _iconFallback(ColorScheme colorScheme, double size) { + Widget _iconFallback( + BuildContext context, + ColorScheme colorScheme, + double size, + ) { return Container( width: size, height: size, @@ -409,7 +553,10 @@ class _PlaylistPickerThumbnail extends StatelessWidget { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), + child: Icon( + context.isMornye ? CupertinoIcons.music_note_list : Icons.queue_music, + color: colorScheme.onSurfaceVariant, + ), ); } } diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart index b908e9ea..37fc80e7 100644 --- a/lib/widgets/re_enrich_field_dialog.dart +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; import '../l10n/app_localizations.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; @@ -156,50 +160,34 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ), ), ), - SettingsGroup( + _options( children: [ - ListTile( - leading: const Icon(Icons.fingerprint), - title: Text(l10n.trackReEnrichModeIsrc), - subtitle: Text(l10n.trackReEnrichModeIsrcSubtitle), - trailing: _mode == ReEnrichBatchMode.isrcOnly - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.isrcOnly), + _modeOption( + icon: Icons.fingerprint, + title: l10n.trackReEnrichModeIsrc, + subtitle: l10n.trackReEnrichModeIsrcSubtitle, + mode: ReEnrichBatchMode.isrcOnly, ), const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.playlist_add_check), - title: Text(l10n.trackReEnrichModeMissing), - subtitle: Text(l10n.trackReEnrichModeMissingSubtitle), - trailing: _mode == ReEnrichBatchMode.missingOnly - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.missingOnly), + _modeOption( + icon: Icons.playlist_add_check, + title: l10n.trackReEnrichModeMissing, + subtitle: l10n.trackReEnrichModeMissingSubtitle, + mode: ReEnrichBatchMode.missingOnly, ), const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.tune), - title: Text(l10n.trackReEnrichModeReplace), - subtitle: Text(l10n.trackReEnrichModeReplaceSubtitle), - trailing: _mode == ReEnrichBatchMode.selectedFields - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.selectedFields), + _modeOption( + icon: Icons.tune, + title: l10n.trackReEnrichModeReplace, + subtitle: l10n.trackReEnrichModeReplaceSubtitle, + mode: ReEnrichBatchMode.selectedFields, ), const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.edit_note), - title: Text(l10n.trackReEnrichModeManual), - subtitle: Text(l10n.trackReEnrichModeManualSubtitle), - trailing: _mode == ReEnrichBatchMode.manualValues - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.manualValues), + _modeOption( + icon: Icons.edit_note, + title: l10n.trackReEnrichModeManual, + subtitle: l10n.trackReEnrichModeManualSubtitle, + mode: ReEnrichBatchMode.manualValues, ), ], ), @@ -215,26 +203,20 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ), ), ), - SettingsGroup( + _options( children: [ - CheckboxListTile( - title: Text( - l10n.trackReEnrichSelectAll, - style: const TextStyle(fontWeight: FontWeight.w600), - ), + _fieldToggle( + title: l10n.trackReEnrichSelectAll, value: _allSelected, - tristate: true, onChanged: _toggleAll, - controlAffinity: ListTileControlAffinity.leading, ), for (final field in ReEnrichFields.all) ...[ const Divider(height: 1, indent: 56), - CheckboxListTile( - secondary: Icon(_iconFor(field), size: 20), - title: Text(_labelFor(field, l10n)), + _fieldToggle( + icon: _iconFor(field), + title: _labelFor(field, l10n), value: _selected.contains(field), onChanged: (value) => _toggle(field, value), - controlAffinity: ListTileControlAffinity.leading, ), ], ], @@ -264,7 +246,8 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ], ), ), - SettingsGroup( + _options( + grouped: false, children: [ for ( var index = 0; @@ -274,19 +257,50 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { if (index > 0) const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), - child: TextField( - controller: - _manualControllers[manualBatchMetadataFields[index]], - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: _manualLabelFor( - manualBatchMetadataFields[index], - l10n, - ), - border: const OutlineInputBorder(), - isDense: true, - ), - ), + child: context.isMornye + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _manualLabelFor( + manualBatchMetadataFields[index], + l10n, + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + CupertinoTextField( + controller: + _manualControllers[manualBatchMetadataFields[index]], + onChanged: (_) => setState(() {}), + style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + decoration: BoxDecoration( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(16), + ), + ), + ], + ) + : TextField( + controller: + _manualControllers[manualBatchMetadataFields[index]], + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: _manualLabelFor( + manualBatchMetadataFields[index], + l10n, + ), + border: const OutlineInputBorder(), + isDense: true, + ), + ), ), ], ], @@ -297,7 +311,7 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: (_mode == ReEnrichBatchMode.selectedFields && _selected.isEmpty) || @@ -322,4 +336,126 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ), ); } + + Widget _options({required List children, bool grouped = true}) { + if (!context.isMornye) return SettingsGroup(children: children); + final content = Column(mainAxisSize: MainAxisSize.min, children: children); + return Padding( + padding: EdgeInsets.symmetric(horizontal: grouped ? 16 : 4, vertical: 4), + child: grouped + ? Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: content, + ) + : content, + ); + } + + Widget _modeOption({ + required IconData icon, + required String title, + required String subtitle, + required ReEnrichBatchMode mode, + }) { + void select() => setState(() => _mode = mode); + final scheme = Theme.of(context).colorScheme; + if (!context.isMornye) { + return ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: _mode == mode + ? Icon(Icons.check, color: scheme.primary) + : null, + onTap: select, + ); + } + return Semantics( + selected: _mode == mode, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + onPressed: select, + child: Row( + children: [ + Icon(mornyeIconFor(icon), color: scheme.primary, size: 24), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 20, + child: _mode == mode + ? Icon( + CupertinoIcons.checkmark, + color: scheme.primary, + size: 20, + ) + : null, + ), + ], + ), + ), + ); + } + + Widget _fieldToggle({ + required String title, + required bool value, + required ValueChanged onChanged, + IconData? icon, + }) { + if (!context.isMornye) { + return CheckboxListTile( + title: Text(title), + value: value, + onChanged: onChanged, + secondary: icon == null ? null : Icon(icon, size: 20), + controlAffinity: ListTileControlAffinity.leading, + ); + } + final scheme = Theme.of(context).colorScheme; + return Semantics( + checked: value, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + onPressed: () => onChanged(!value), + child: Row( + children: [ + Icon( + value + ? CupertinoIcons.check_mark_circled_solid + : CupertinoIcons.circle, + color: value ? scheme.primary : scheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(width: 14), + Expanded( + child: Text(title, style: Theme.of(context).textTheme.bodyLarge), + ), + if (icon != null) + Icon( + mornyeIconFor(icon), + color: scheme.onSurfaceVariant, + size: 20, + ), + ], + ), + ), + ); + } } diff --git a/lib/widgets/re_enrich_review_sheet.dart b/lib/widgets/re_enrich_review_sheet.dart index f9e72c42..5f031f21 100644 --- a/lib/widgets/re_enrich_review_sheet.dart +++ b/lib/widgets/re_enrich_review_sheet.dart @@ -3,6 +3,9 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; Future showReEnrichReviewSheet( BuildContext context, { @@ -108,70 +111,79 @@ class _ReEnrichReviewContent extends StatelessWidget { itemCount: visible.length, itemBuilder: (context, index) { final preview = visible[index]; - return SettingsGroup( - children: [ - ListTile( - leading: const Icon(Icons.audio_file_outlined), - title: Text( - preview.item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - preview.item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + final children = [ + ListTile( + leading: const Icon(Icons.audio_file_outlined), + title: Text( + preview.item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - for (final change in preview.changes) ...[ - const Divider(height: 1, indent: 56), - Semantics( - label: - '${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}', - child: Padding( - padding: const EdgeInsets.fromLTRB(56, 10, 16, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _fieldLabel(context, change.field), - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( + subtitle: Text( + preview.item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + for (final change in preview.changes) ...[ + const Divider(height: 1, indent: 56), + Semantics( + label: + '${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}', + child: Padding( + padding: const EdgeInsets.fromLTRB(56, 10, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _fieldLabel(context, change.field), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 3), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: _oldValue(context, change), + style: TextStyle( color: colorScheme.onSurfaceVariant, ), + ), + TextSpan( + text: ' → ', + style: TextStyle( + color: colorScheme.primary, + ), + ), + TextSpan( + text: _newValue(context, change), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ], ), - const SizedBox(height: 3), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: _oldValue(context, change), - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - ), - TextSpan( - text: ' → ', - style: TextStyle( - color: colorScheme.primary, - ), - ), - TextSpan( - text: _newValue(context, change), - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), + ), + ], ), ), - ], + ), ], - ); + ]; + return context.isMornye + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: AppContentCard( + child: Column(children: children), + ), + ) + : SettingsGroup(children: children); }, ), ), @@ -179,7 +191,7 @@ class _ReEnrichReviewContent extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: () => Navigator.pop(context, true), icon: const Icon(Icons.save_outlined), label: Text(context.l10n.trackReEnrichApplyChanges), diff --git a/lib/widgets/selection_action_button.dart b/lib/widgets/selection_action_button.dart index c6a97d46..644fb2ad 100644 --- a/lib/widgets/selection_action_button.dart +++ b/lib/widgets/selection_action_button.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; /// Icon+label pill used in selection-mode bottom bars. Disabled state dims /// both the fill and the content to 50% alpha. @@ -19,6 +21,16 @@ class SelectionActionButton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return AppActionButton( + outlined: true, + tonal: true, + onPressed: onPressed, + icon: Icon(icon), + label: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis), + fontSize: 15, + ); + } final isDisabled = onPressed == null; return Semantics( button: true, diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index 49ce6bb1..c7db3c00 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -225,31 +228,35 @@ class SelectionBottomBar extends StatelessWidget { final tokens = context.tokens; final colorScheme = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.vertical( - top: Radius.circular(tokens.radiusSheet), - ), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), + final content = SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), - Row( - children: [ + Row( + children: [ + if (context.isMornye) + Tooltip( + message: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + child: CupertinoButton( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.all(12), + onPressed: onClose, + child: Icon( + CupertinoIcons.xmark, + color: colorScheme.onSurface, + size: 24, + ), + ), + ) + else IconButton.filledTonal( onPressed: onClose, tooltip: MaterialLocalizations.of( @@ -260,30 +267,45 @@ class SelectionBottomBar extends StatelessWidget { backgroundColor: colorScheme.surfaceContainerHighest, ), ), - const SizedBox(width: 12), + const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.selectionSelected(selectedCount), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.selectionSelected(selectedCount), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? allSelectedLabel ?? + context.l10n.selectionAllSelected + : tapToSelectLabel ?? + context.l10n.downloadedAlbumTapToSelect, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - Text( - allSelected - ? allSelectedLabel ?? - context.l10n.selectionAllSelected - : tapToSelectLabel ?? - context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), + ), + ], ), + ), + if (context.isMornye) + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 8), + onPressed: onToggleSelectAll, + child: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.primary, + ), + ), + ) + else TextButton.icon( onPressed: onToggleSelectAll, icon: Icon( @@ -299,16 +321,34 @@ class SelectionBottomBar extends StatelessWidget { foregroundColor: colorScheme.primary, ), ), - ], - ), + ], + ), - const SizedBox(height: 12), + const SizedBox(height: 12), - ...children, - ], - ), + ...children, + ], ), ), ); + if (context.isMornye) { + return MornyeGlassPanel.overlay(child: content); + } + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.vertical( + top: Radius.circular(tokens.radiusSheet), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: content, + ); } } diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index a55b81d8..7af25a94 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -1,7 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Background fill for grouped cards, matching the Settings group look. Blends a @@ -9,6 +14,7 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// black) dark themes as well as normal light/dark themes. Color settingsGroupColor(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) return colorScheme.surfaceContainer; final isDark = Theme.of(context).brightness == Brightness.dark; return isDark ? Color.alphaBlend( @@ -182,8 +188,14 @@ class _SettingsSearchTargetState extends State { class SettingsGroup extends StatelessWidget { final List children; final EdgeInsetsGeometry? margin; + final bool glass; - const SettingsGroup({super.key, required this.children, this.margin}); + const SettingsGroup({ + super.key, + required this.children, + this.margin, + this.glass = false, + }); @override Widget build(BuildContext context) { @@ -193,15 +205,35 @@ class SettingsGroup extends StatelessWidget { final decoration = BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(context.tokens.radiusCard), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), + border: context.isMornye + ? null + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ); final child = Material( color: Colors.transparent, child: Column(mainAxisSize: MainAxisSize.min, children: children), ); + if (glass && context.isMornye) { + return LayoutBuilder( + builder: (context, constraints) => Padding( + padding: + margin ?? + EdgeInsets.symmetric( + horizontal: + 16 + + (constraints.hasBoundedWidth + ? wideInsetForWidth(constraints.maxWidth) + : 0), + vertical: 4, + ), + child: MornyeGlassPanel(child: child), + ), + ); + } + // Explicit caller margin wins as-is. Otherwise center on wide surfaces // using the incoming constraint (not screen width) so groups nested in an // already clamped box, e.g. a bottom sheet, are not over-inset. @@ -263,64 +295,90 @@ class SettingsItem extends StatelessWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: Colors.transparent, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - if (icon != null) ...[ - Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), - const SizedBox(width: 16), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - title, - style: Theme.of(context).textTheme.bodyLarge, + padding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 16 : 20, + vertical: context.isMornye ? 10 : 16, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: context.isMornye ? 28 : 0), + child: Row( + children: [ + if (icon != null) ...[ + Icon( + context.isMornye ? mornyeIconFor(icon!) : icon, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + size: 24, + ), + SizedBox(width: context.isMornye ? 12 : 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), ), - ), - if (titleTrailing != null) ...[ - const SizedBox(width: 8), - titleTrailing!, + if (titleTrailing != null) ...[ + const SizedBox(width: 8), + titleTrailing!, + ], ], - ], - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - Text( - subtitle!, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: + (context.isMornye + ? Theme.of(context).textTheme.bodySmall + : Theme.of( + context, + ).textTheme.bodyMedium) + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 8), - trailing!, - ] else if (onTap != null) ...[ - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, + ), ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ] else if (onTap != null) ...[ + const SizedBox(width: 8), + Icon( + context.isMornye + ? CupertinoIcons.chevron_forward + : Icons.chevron_right, + size: context.isMornye ? 18 : 24, + color: colorScheme.onSurfaceVariant, + ), + ], ], - ], + ), ), ), ), if (showDivider) Divider( height: 1, - thickness: 1, - indent: icon != null ? 56 : 20, - endIndent: 20, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), + thickness: context.isMornye ? 0.5 : 1, + indent: context.isMornye + ? (icon != null ? 52 : 16) + : (icon != null ? 56 : 20), + endIndent: context.isMornye ? 16 : 20, + color: context.isMornye + ? colorScheme.outlineVariant + : colorScheme.outlineVariant.withValues(alpha: 0.3), ), ], ); @@ -365,18 +423,23 @@ class SettingsSwitchItem extends StatelessWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: Colors.transparent, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 16 : 20, + vertical: context.isMornye ? 8 : 12, + ), child: Row( children: [ if (icon != null) ...[ Icon( - icon, + context.isMornye ? mornyeIconFor(icon!) : icon, color: isDisabled ? colorScheme.outline + : context.isMornye + ? colorScheme.primary : colorScheme.onSurfaceVariant, size: 24, ), - const SizedBox(width: 16), + SizedBox(width: context.isMornye ? 12 : 16), ], Expanded( child: Column( @@ -418,8 +481,9 @@ class SettingsSwitchItem extends StatelessWidget { ), ), const SizedBox(width: 8), - Switch( + AppSwitch( value: value, + semanticLabel: title, onChanged: isDisabled ? null : onChanged, ), ], @@ -430,10 +494,14 @@ class SettingsSwitchItem extends StatelessWidget { if (showDivider) Divider( height: 1, - thickness: 1, - indent: icon != null ? 56 : 20, - endIndent: 20, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), + thickness: context.isMornye ? 0.5 : 1, + indent: context.isMornye + ? (icon != null ? 52 : 16) + : (icon != null ? 56 : 20), + endIndent: context.isMornye ? 16 : 20, + color: context.isMornye + ? colorScheme.outlineVariant + : colorScheme.outlineVariant.withValues(alpha: 0.3), ), ], ); @@ -451,11 +519,19 @@ class SettingsSectionHeader extends StatelessWidget { final content = Padding( padding: const EdgeInsets.fromLTRB(32, 24, 32, 8), child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), + context.isMornye ? title.toUpperCase() : title, + style: + (context.isMornye + ? Theme.of(context).textTheme.bodySmall + : Theme.of(context).textTheme.titleSmall) + ?.copyWith( + color: context.isMornye + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, + fontWeight: context.isMornye + ? FontWeight.normal + : FontWeight.w600, + ), ), ); return SettingsSearchTarget(label: title, child: content); @@ -500,13 +576,17 @@ class SettingsChoiceChip extends StatelessWidget { final isDark = theme.brightness == Brightness.dark; final borderRadius = BorderRadius.circular(tokens.radiusCover); - final unselectedColor = isDark + final unselectedColor = context.isMornye + ? Colors.transparent + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, ) : colorScheme.surfaceContainerHigh; - final foreground = isSelected + final foreground = context.isMornye && isSelected + ? colorScheme.onSurface + : isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant; @@ -528,7 +608,10 @@ class SettingsChoiceChip extends StatelessWidget { content = Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: foreground), + Icon( + context.isMornye ? mornyeIconFor(icon!) : icon, + color: foreground, + ), SizedBox(height: tokens.gapXs + 2), labelText, ], @@ -537,7 +620,11 @@ class SettingsChoiceChip extends StatelessWidget { content = Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 18, color: foreground), + Icon( + context.isMornye ? mornyeIconFor(icon!) : icon, + size: 18, + color: foreground, + ), SizedBox(width: tokens.gapSm), Flexible(child: labelText), ], @@ -545,7 +632,11 @@ class SettingsChoiceChip extends StatelessWidget { } final chip = Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, + color: isSelected + ? (context.isMornye + ? colorScheme.surfaceContainerHighest + : colorScheme.primaryContainer) + : unselectedColor, borderRadius: borderRadius, child: InkWell( onTap: onTap, @@ -608,6 +699,7 @@ class SettingsInfoCard extends StatelessWidget { this.tone = SettingsInfoTone.neutral, this.action, this.margin, + this.glass = false, }); final String message; @@ -620,6 +712,9 @@ class SettingsInfoCard extends StatelessWidget { final EdgeInsetsGeometry? margin; + /// Uses the shared glass surface in Mornye without changing Material cards. + final bool glass; + @override Widget build(BuildContext context) { final tokens = context.tokens; @@ -641,23 +736,19 @@ class SettingsInfoCard extends StatelessWidget { ), }; - return Container( - margin: - margin ?? - EdgeInsets.symmetric( - horizontal: tokens.gapLg, - vertical: tokens.gapXs, - ), + final useGlass = glass && context.isMornye; + final textColor = useGlass ? colorScheme.onSurface : foreground; + final content = Padding( padding: EdgeInsets.all(tokens.gapLg), - decoration: BoxDecoration( - color: background, - borderRadius: tokens.borderRadiusCard, - ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ - Icon(icon, size: 20, color: foreground), + Icon( + useGlass ? mornyeIconFor(icon!) : icon, + size: 20, + color: useGlass ? colorScheme.primary : foreground, + ), SizedBox(width: tokens.gapMd), ], Expanded( @@ -669,7 +760,7 @@ class SettingsInfoCard extends StatelessWidget { Text( title!, style: theme.textTheme.bodyMedium?.copyWith( - color: foreground, + color: textColor, fontWeight: FontWeight.w600, ), ), @@ -677,7 +768,7 @@ class SettingsInfoCard extends StatelessWidget { ], Text( message, - style: theme.textTheme.bodySmall?.copyWith(color: foreground), + style: theme.textTheme.bodySmall?.copyWith(color: textColor), ), if (action != null) ...[ SizedBox(height: tokens.gapSm), @@ -689,5 +780,22 @@ class SettingsInfoCard extends StatelessWidget { ], ), ); + return Padding( + padding: + margin ?? + EdgeInsets.symmetric( + horizontal: tokens.gapLg, + vertical: tokens.gapXs, + ), + child: useGlass + ? MornyeGlassPanel.overlay(child: content) + : DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: tokens.borderRadiusCard, + ), + child: content, + ), + ); } } diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart index 9f474681..bbd6ffcf 100644 --- a/lib/widgets/track_card.dart +++ b/lib/widgets/track_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; /// How a [TrackCard] paints its container. @@ -80,13 +81,13 @@ class TrackCard extends StatelessWidget { final Color? cardColor; if (isSelected) { cardColor = colorScheme.primaryContainer.withValues(alpha: 0.3); - } else if (style == TrackCardStyle.flat) { + } else if (style == TrackCardStyle.flat || context.isMornye) { cardColor = Colors.transparent; } else { cardColor = null; // Inherits cardTheme.color. } - return Card( + final card = Card( elevation: 0, // The two styles carry their own outer margin so no call site has to // repeat it: flat rows sit tight inside a collection list, filled cards @@ -163,6 +164,20 @@ class TrackCard extends StatelessWidget { ), ), ); + if (!context.isMornye) return card; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + card, + Padding( + padding: EdgeInsetsDirectional.only( + start: tokens.coverList + 40, + end: 20, + ), + child: const Divider(), + ), + ], + ); } } diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index a104e698..ae9e2d9c 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -1,5 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/widgets/track_collection_action_policy.dart'; import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -31,12 +35,22 @@ class TrackCollectionQuickActions extends ConsumerWidget { bool hasLocalPlaybackCandidate = false, }) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + showMornyeContextMenu( + context: context, + builder: (_) => _TrackOptionsSheet( + track: track, + hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, + ), + ); + return; + } showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerHigh, - builder: (sheetContext) => _TrackOptionsSheet( + builder: (_) => _TrackOptionsSheet( track: track, hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, ), @@ -50,7 +64,7 @@ class TrackCollectionQuickActions extends ConsumerWidget { return IconButton( tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( - Icons.more_vert, + context.isMornye ? CupertinoIcons.ellipsis : Icons.more_vert, color: colorScheme.onSurfaceVariant, size: 20, ), @@ -93,7 +107,111 @@ class _TrackOptionsSheet extends ConsumerWidget { hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, ); + if (context.isMornye) { + final l10n = context.l10n; + return MornyeContextMenu( + quickActions: [ + MornyeMenuAction( + icon: CupertinoIcons.arrow_down_circle_fill, + label: l10n.dialogDownload, + onPressed: () { + final root = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + downloadSingleTrack(root, ref, track); + }, + ), + MornyeMenuAction( + icon: CupertinoIcons.star_fill, + label: isLoved ? l10n.mornyeFavorited : l10n.mornyeFavorite, + selected: isLoved, + onPressed: () => _toggleCollection(context, ref, wishlist: false), + ), + MornyeMenuAction( + icon: CupertinoIcons.share_solid, + label: l10n.trackMetadataShare, + onPressed: () { + final origin = + mornyeMenuAnchor(context) ?? + Rect.fromCenter( + center: MediaQuery.sizeOf(context).center(Offset.zero), + width: 1, + height: 1, + ); + Navigator.pop(context); + SharePlus.instance.share( + ShareParams( + text: '${track.name} — ${track.artistName}', + sharePositionOrigin: origin, + ), + ); + }, + ), + ], + groups: [ + [ + if (qualityVariantAction == QualityVariantMenuAction.playLocal) + MornyeMenuAction( + icon: CupertinoIcons.play, + label: l10n.trackMetadataPlay, + onPressed: () => _playLocal(context, ref), + ), + if (qualityVariantAction == + QualityVariantMenuAction.downloadAnotherQuality) + MornyeMenuAction( + icon: CupertinoIcons.arrow_down_circle, + label: l10n.trackOptionDownloadQualityVariant, + onPressed: () => _downloadQualityVariant(context, ref), + ), + MornyeMenuAction( + icon: CupertinoIcons.square_stack, + label: l10n.homeGoToAlbum, + onPressed: () => _goToAlbum(context), + ), + ], + [ + MornyeMenuAction( + icon: CupertinoIcons.text_badge_plus, + label: l10n.collectionAddToPlaylist, + onPressed: () { + final root = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + showAddTrackToPlaylistSheet(root, ref, track); + }, + ), + MornyeMenuAction( + icon: isInWishlist + ? CupertinoIcons.checkmark_circle + : CupertinoIcons.add_circled, + label: isInWishlist + ? l10n.trackOptionRemoveFromWishlist + : l10n.trackOptionAddToWishlist, + onPressed: () => _toggleCollection(context, ref, wishlist: true), + ), + ], + [ + MornyeMenuAction( + icon: CupertinoIcons.doc_on_doc, + label: l10n.trackOptionCopyTrackName, + onPressed: () => _copyText(context, track.name), + ), + MornyeMenuAction( + icon: CupertinoIcons.person, + label: l10n.trackOptionCopyArtist, + onPressed: () => _copyText(context, track.artistName), + ), + MornyeMenuAction( + icon: CupertinoIcons.doc_on_doc, + label: l10n.trackOptionCopyTrackAndArtist, + onPressed: () => + _copyText(context, '${track.name} - ${track.artistName}'), + ), + ], + ], + ); + } + return SafeArea( + top: false, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.82, @@ -274,8 +392,12 @@ class _TrackOptionsSheet extends ConsumerWidget { icon: Icons.playlist_add, title: context.l10n.collectionAddToPlaylist, onTap: () { + final rootContext = Navigator.of( + context, + rootNavigator: true, + ).context; Navigator.pop(context); - showAddTrackToPlaylistSheet(context, ref, track); + showAddTrackToPlaylistSheet(rootContext, ref, track); }, ), @@ -293,6 +415,29 @@ class _TrackOptionsSheet extends ConsumerWidget { downloadSingleTrack(rootContext, ref, track, forceQualityPicker: true); } + Future _toggleCollection( + BuildContext context, + WidgetRef ref, { + required bool wishlist, + }) async { + final root = Navigator.of(context, rootNavigator: true).context; + final notifier = ref.read(libraryCollectionsProvider.notifier); + Navigator.pop(context); + final added = wishlist + ? await notifier.toggleWishlist(track) + : await notifier.toggleLoved(track); + if (!root.mounted) return; + final l10n = root.l10n; + final message = wishlist + ? (added + ? l10n.collectionAddedToWishlist(track.name) + : l10n.collectionRemovedFromWishlist(track.name)) + : (added + ? l10n.collectionAddedToLoved(track.name) + : l10n.collectionRemovedFromLoved(track.name)); + ScaffoldMessenger.of(root).showSnackBar(SnackBar(content: Text(message))); + } + void _copyText(BuildContext context, String text) { final rootContext = Navigator.of(context, rootNavigator: true).context; Navigator.pop(context); diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index 206ff69d..c6ad357e 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -30,6 +31,8 @@ void downloadSingleTrack( } if (settings.askQualityBeforeDownload || forceQualityPicker) { + // A context menu may be disposed before the quality picker is submitted. + final container = ProviderScope.containerOf(context, listen: false); DownloadServicePicker.show( context, trackName: track.name, @@ -37,7 +40,7 @@ void downloadSingleTrack( coverUrl: track.coverUrl, recommendedService: recommendedService, onSelect: (quality, service) { - ref + container .read(downloadQueueProvider.notifier) .addToQueue( track, @@ -82,20 +85,19 @@ void confirmDownloadAllDialog( VoidCallback onConfirm, ) { if (trackCount == 0) return; - showDialog( + showAppDialog( context: context, builder: (dialogContext) { - final colorScheme = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: colorScheme.surfaceContainerHigh, + return AppAlertDialog( title: Text(context.l10n.dialogDownloadAllTitle), content: Text(context.l10n.dialogDownloadAllMessage(trackCount)), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(dialogContext); onConfirm(); diff --git a/lib/widgets/track_list_tile.dart b/lib/widgets/track_list_tile.dart index e8b0d977..3205c5c2 100644 --- a/lib/widgets/track_list_tile.dart +++ b/lib/widgets/track_list_tile.dart @@ -23,6 +23,7 @@ class TrackListTile extends ConsumerWidget { final void Function({bool forceQualityPicker}) onDownload; final Widget leading; final bool clickableArtist; + final bool showQualityBadges; final bool isSelectionMode; final bool isSelected; final VoidCallback? onToggleSelection; @@ -35,6 +36,7 @@ class TrackListTile extends ConsumerWidget { required this.onDownload, required this.leading, this.clickableArtist = false, + this.showQualityBadges = true, this.isSelectionMode = false, this.isSelected = false, this.onToggleSelection, @@ -95,8 +97,8 @@ class TrackListTile extends ConsumerWidget { ), badges: [ ...buildQualityBadges( - audioQuality: track.audioQuality, - audioModes: track.audioModes, + audioQuality: showQualityBadges ? track.audioQuality : null, + audioModes: showQualityBadges ? track.audioModes : null, colorScheme: colorScheme, explicit: track.isExplicit, ), diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 49af193e..aad843f9 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -6,6 +6,9 @@ import 'package:spotiflac_android/services/apk_downloader.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class UpdateDialog extends StatefulWidget { final UpdateInfo updateInfo; @@ -133,7 +136,7 @@ class _UpdateDialogState extends State { return PopScope( canPop: !widget.forced, - child: Dialog( + child: AppDialogSurface( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: ConstrainedBox( @@ -198,7 +201,9 @@ class _UpdateDialogState extends State { vertical: 12, ), decoration: BoxDecoration( - color: isDark + color: context.isMornye + ? MornyeTheme.controlFill(context) + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.08), colorScheme.surface, @@ -208,17 +213,23 @@ class _UpdateDialogState extends State { colorScheme.surface, ), borderRadius: BorderRadius.circular(16), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), + border: context.isMornye + ? null + : Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.5, + ), + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _VersionChip( - version: AppInfo.displayVersion, - label: context.l10n.updateCurrent, - colorScheme: colorScheme, + Flexible( + child: _VersionChip( + version: AppInfo.displayVersion, + label: context.l10n.updateCurrent, + colorScheme: colorScheme, + ), ), const SizedBox(width: 12), Icon( @@ -227,11 +238,13 @@ class _UpdateDialogState extends State { color: colorScheme.primary, ), const SizedBox(width: 12), - _VersionChip( - version: widget.updateInfo.version, - label: context.l10n.updateNew, - colorScheme: colorScheme, - isNew: true, + Flexible( + child: _VersionChip( + version: widget.updateInfo.version, + label: context.l10n.updateNew, + colorScheme: colorScheme, + isNew: true, + ), ), ], ), @@ -242,7 +255,9 @@ class _UpdateDialogState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: isDark + color: context.isMornye + ? MornyeTheme.controlFill(context) + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, @@ -319,7 +334,9 @@ class _UpdateDialogState extends State { Container( constraints: const BoxConstraints(maxHeight: 180), decoration: BoxDecoration( - color: isDark + color: context.isMornye + ? MornyeTheme.controlFill(context) + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, @@ -351,7 +368,8 @@ class _UpdateDialogState extends State { width: double.infinity, child: widget.forced ? const SizedBox.shrink() - : OutlinedButton( + : AppDialogAction( + outlined: true, onPressed: () => Navigator.pop(context), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), @@ -367,7 +385,7 @@ class _UpdateDialogState extends State { children: [ SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: _downloadAndInstall, icon: const Icon(Icons.download_rounded, size: 20), label: Text(context.l10n.updateDownloadInstall), @@ -384,7 +402,7 @@ class _UpdateDialogState extends State { Row( children: [ Expanded( - child: TextButton( + child: AppDialogAction( onPressed: () { widget.onDisableUpdates(); Navigator.pop(context); @@ -407,7 +425,8 @@ class _UpdateDialogState extends State { ), const SizedBox(width: 8), Expanded( - child: OutlinedButton( + child: AppDialogAction( + outlined: true, onPressed: () { widget.onDismiss(); Navigator.pop(context); @@ -529,15 +548,20 @@ class _VersionChip extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: isNew + color: context.isMornye + ? Colors.transparent + : isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Text( 'v$version', + textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isNew + color: context.isMornye && isNew + ? colorScheme.primary + : isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, fontWeight: isNew ? FontWeight.bold : FontWeight.w500, @@ -555,7 +579,7 @@ Future showUpdateDialog( required VoidCallback onDisableUpdates, bool forced = false, }) async { - return showDialog( + return showAppDialog( context: context, barrierDismissible: !forced, builder: (context) => UpdateDialog( diff --git a/pubspec.lock b/pubspec.lock index d32cdad1..44e18bdf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" cupertino_ui: dependency: transitive description: @@ -797,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + liquid_glass_easy: + dependency: "direct main" + description: + name: liquid_glass_easy + sha256: "5b3f01c8bea8f4f2b88387be9426eef61657cb29a26297ae431a02fed2ac6b95" + url: "https://pub.dev" + source: hosted + version: "4.3.1" listen: dependency: transitive description: @@ -1474,6 +1490,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.3.0" + volume_controller: + dependency: "direct main" + description: + name: volume_controller + sha256: cbd59f996c0b577ae2df5fc6cf2c5d9c98db259395ab5ae749d313d20ddca30f + url: "https://pub.dev" + source: hosted + version: "3.7.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 10f185c1..27e7d70a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: # UI Components cached_network_image: ^4.0.0 flutter_cache_manager: ^3.4.2 + liquid_glass_easy: ^4.3.1 + cupertino_icons: ^1.0.9 + volume_controller: ^3.6.1 # Material Expressive 3 / Dynamic Color dynamic_color: ^2.1.0 @@ -104,8 +107,12 @@ flutter: assets: - assets/images/ + - assets/fonts/Inter-LICENSE.txt fonts: + - family: Inter + fonts: + - asset: assets/fonts/InterVariable.ttf - family: Google Sans Flex fonts: - asset: assets/fonts/GoogleSansFlex.ttf diff --git a/test/adaptive_overlay_test.dart b/test/adaptive_overlay_test.dart new file mode 100644 index 00000000..a50b23e8 --- /dev/null +++ b/test/adaptive_overlay_test.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/app_remote_config_service.dart'; +import 'package:spotiflac_android/services/update_checker.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_announcement_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/update_dialog.dart'; + +Widget _app(ThemeData theme, Widget home) => ProviderScope( + child: MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, + ), +); + +void main() { + for (final mornye in [false, true]) { + for (final brightness in Brightness.values) { + final theme = mornye + ? MornyeTheme.build(brightness) + : ThemeData(brightness: brightness); + final variant = 'Mornye: $mornye, $brightness'; + + testWidgets('sheet selects, disables and returns values ($variant)', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 740); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var selected = false; + var disabledTaps = 0; + bool? result; + await tester.pumpWidget( + _app( + theme, + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open'), + onPressed: () async { + result = await showAppBottomSheet( + context: context, + title: 'Library filter', + builder: (context) => StatefulBuilder( + builder: (context, setSheetState) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + children: [ + AppChoiceChip( + label: const Text('Downloaded tracks'), + selected: selected, + onSelected: (value) => setSheetState(() { + selected = value; + }), + ), + ], + ), + AppSheetOption( + enabled: false, + title: const Text('Unavailable'), + onTap: () => disabledTaps++, + ), + AppSheetOption( + title: const Text('Apply'), + onTap: () => Navigator.pop(context, selected), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect( + find.byType(MornyeGlassPanel), + mornye ? findsOneWidget : findsNothing, + ); + expect(find.byType(FilterChip), mornye ? findsNothing : findsOneWidget); + await tester.tap(find.text('Unavailable')); + expect(disabledTaps, 0); + await tester.tap(find.text('Downloaded tracks')); + await tester.pumpAndSettle(); + expect(selected, isTrue); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(find.text('Library filter'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('nested dialog retains theme and navigation ($variant)', ( + tester, + ) async { + final nestedNavigator = GlobalKey(); + bool? result; + await tester.pumpWidget( + _app( + ThemeData(), + Navigator( + key: nestedNavigator, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => Theme( + data: theme, + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open import'), + onPressed: () async { + result = await showAppDialog( + context: context, + useRootNavigator: false, + builder: (context) => AppAlertDialog( + title: const Text('Import playlist'), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(context, true), + child: const Text('Confirm'), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open import')); + await tester.pumpAndSettle(); + expect(nestedNavigator.currentState!.canPop(), isTrue); + expect( + find.byType(MornyeGlassPanel), + mornye ? findsOneWidget : findsNothing, + ); + expect( + Theme.of(tester.element(find.text('Import playlist'))).brightness, + brightness, + ); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(nestedNavigator.currentState!.canPop(), isFalse); + expect(find.text('Open import'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('required update resists back and barrier ($variant)', ( + tester, + ) async { + tester.view.physicalSize = const Size(393, 740); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _app( + theme, + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Update'), + onPressed: () => showUpdateDialog( + context, + updateInfo: UpdateInfo( + version: '9.0.0', + changelog: 'Playback and library improvements.\n' * 8, + downloadUrl: 'https://example.com/release', + publishedAt: DateTime(2026), + releasesBehind: 3, + ), + onDisableUpdates: () => + fail('Cannot disable required update'), + forced: true, + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tapAt(const Offset(5, 5)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(UpdateDialog), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('announcement keeps explicit dismissal (Mornye: $mornye)', ( + tester, + ) async { + var dismissals = 0; + await tester.pumpWidget( + _app( + mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open notice'), + onPressed: () => showAppAnnouncementDialog( + context, + announcement: const RemoteAnnouncement( + id: 'notice', + enabled: true, + title: 'Service notice', + message: 'An announcement without an external action.', + dismissible: false, + ), + onDismiss: () => dismissals++, + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open notice')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(5, 5)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Service notice'), findsOneWidget); + expect(dismissals, 0); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.text('Service notice'), findsNothing); + expect(dismissals, 1); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/album_track_metadata_test.dart b/test/album_track_metadata_test.dart index ee5c177a..993a6d2d 100644 --- a/test/album_track_metadata_test.dart +++ b/test/album_track_metadata_test.dart @@ -8,6 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; void main() { @@ -31,6 +33,9 @@ void main() { 'copyright': 'Example Copyright', 'comment': 'Example Comment', 'upc': '0123456789012', + 'audio_quality': '16-bit', + 'audio_modes': 'DOLBY_ATMOS', + 'explicit': true, }; var albumRequested = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -48,6 +53,7 @@ void main() { 'name': 'Example Album', 'total_tracks': 1, 'album_type': 'album', + 'audio_traits': ['lossless', 'dolby_atmos'], }, }); } @@ -66,6 +72,7 @@ void main() { await tester.pumpWidget( ProviderScope( child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumScreen( @@ -91,6 +98,12 @@ void main() { expect(albumTrack.albumId, 'metadata-album'); expect(albumTrack.albumType, 'album'); expect(albumTrack.totalTracks, 1); + expect(albumTrack.audioQuality, '16-bit'); + expect(find.text('Lossless'), findsOneWidget); + expect(find.text('Dolby Atmos'), findsOneWidget); + expect(find.byType(AudioQualityBadge), findsNothing); + expect(find.byType(DolbyAtmosBadge), findsNothing); + expect(find.byType(ExplicitBadge), findsOneWidget); await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); }); diff --git a/test/app_alert_dialog_test.dart b/test/app_alert_dialog_test.dart new file mode 100644 index 00000000..e2491619 --- /dev/null +++ b/test/app_alert_dialog_test.dart @@ -0,0 +1,221 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + for (final brightness in Brightness.values) { + for (final reduceMotion in [false, true]) { + testWidgets( + 'confirmation keeps blur during entry and cancels promptly ($brightness, reduce motion: $reduceMotion)', + (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reduceMotion), + child: child!, + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open'), + onPressed: () => showAppDialog( + context: context, + builder: (context) => AppAlertDialog( + title: const Text('Delete Selected'), + content: const Text( + 'This will also delete the files.', + ), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + for (final elapsed in [16, 64, 180]) { + await tester.pump(Duration(milliseconds: elapsed)); + final blur = find.descendant( + of: find.byType(AppAlertDialog), + matching: find.byType(BackdropFilter), + ); + expect(blur, findsOneWidget); + // An opacity layer isolates the filter from the underlying page + // until the route finishes entering. + final fades = tester.widgetList( + find.ancestor(of: blur, matching: find.byType(FadeTransition)), + ); + expect(fades.every((fade) => fade.opacity.value == 1), isTrue); + final bodyContext = tester.element( + find.text('This will also delete the files.'), + ); + expect( + DefaultTextStyle.of(bodyContext).style.color, + Theme.of(bodyContext).colorScheme.onSurface, + ); + } + final panel = find.byType(MornyeGlassPanel); + final openWidth = tester.getRect(panel).width; + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + if (!reduceMotion) { + // Closing must move on the first frame, rather than holding the + // fully open scale while a reversed entrance curve settles. + expect(tester.getRect(panel).width, lessThan(openWidth - 1)); + await tester.pump(const Duration(milliseconds: 164)); + await tester.pump(); + } + expect(find.byType(AppAlertDialog), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + } + + for (final mornye in [true, false]) { + testWidgets( + 'confirmation preserves cancel and confirm results (Mornye: $mornye)', + (tester) async { + bool? result; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open'), + onPressed: () async { + result = await showAppDialog( + context: context, + builder: (context) => AppAlertDialog( + title: const Text('Remove extension'), + content: const Text('Remove this example extension?'), + actions: [ + AppDialogAction( + isDefault: true, + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + AppDialogAction( + filled: true, + isDestructive: true, + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect( + find.byType(MornyeGlassPanel), + mornye ? findsOneWidget : findsNothing, + ); + expect( + find.byType(AlertDialog), + mornye ? findsNothing : findsOneWidget, + ); + if (mornye) { + final remove = find.widgetWithText(CupertinoButton, 'Remove'); + final cancel = find.widgetWithText(CupertinoButton, 'Cancel'); + expect( + tester.getRect(remove).bottom, + lessThan(tester.getRect(cancel).top), + ); + expect(tester.getRect(remove).width, tester.getRect(cancel).width); + expect( + DefaultTextStyle.of( + tester.element(find.text('Remove')), + ).style.color, + MornyeTheme.build(Brightness.dark).colorScheme.primary, + ); + expect(find.byType(CupertinoAlertDialog), findsNothing); + expect(find.byType(FilledButton), findsNothing); + } + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(result, isFalse); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(result, isTrue); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(result, isNull); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('glass confirmation keeps actions reachable with long text', ( + tester, + ) async { + tester.view.physicalSize = const Size(393, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.5)), + child: AppAlertDialog( + title: const Text('Delete these tracks from your library?'), + content: Text( + List.filled(12, 'This removes the selected files.').join(' '), + ), + actions: [ + AppDialogAction(onPressed: () {}, child: const Text('Cancel')), + AppDialogAction( + isDestructive: true, + onPressed: () {}, + child: const Text('Delete from Library'), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(find.text('Delete from Library').hitTestable(), findsOneWidget); + final scroll = find.byType(SingleChildScrollView).first; + await tester.drag(scroll, const Offset(0, -150)); + await tester.pumpAndSettle(); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/app_snack_bar_test.dart b/test/app_snack_bar_test.dart new file mode 100644 index 00000000..c39598bb --- /dev/null +++ b/test/app_snack_bar_test.dart @@ -0,0 +1,145 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('snackbar retains backdrop color during entry in $brightness', ( + tester, + ) async { + tester.view.physicalSize = const Size(393, 780); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final capture = GlobalKey(); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (_, child) => RepaintBoundary(key: capture, child: child), + home: Builder( + builder: (context) => Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + const Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: ColoredBox(color: Colors.blue)), + Expanded(child: ColoredBox(color: Colors.red)), + ], + ), + Center( + child: TextButton( + onPressed: () => showAppSnackBar( + context, + content: const Text('Extension installed'), + ), + child: const Text('Show'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + for (final elapsed in [16, 80, 250]) { + await tester.pump(Duration(milliseconds: elapsed)); + final rect = tester.getRect(find.byType(MornyeGlassPanel)); + final colors = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final colors = []; + for (final fraction in [0.25, 0.75]) { + final x = (rect.left + rect.width * fraction).floor(); + final y = (rect.bottom - 8).floor(); + final offset = (y * image.width + x) * 4; + colors.add( + Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ), + ); + } + image.dispose(); + return colors; + }); + // Retain a visible backdrop hue even beneath the glass shadow. + expect(colors![0].b - colors[0].r, greaterThan(0.20)); + expect(colors[1].r - colors[1].b, greaterThan(0.20)); + final foreground = MornyeTheme.build(brightness).colorScheme.onSurface; + for (final background in colors) { + final luminances = [ + foreground.computeLuminance(), + background.computeLuminance(), + ]..sort(); + expect( + (luminances.last + 0.05) / (luminances.first + 0.05), + greaterThanOrEqualTo(4.5), + ); + } + } + expect(tester.takeException(), isNull); + }); + } + + for (final mornye in [true, false]) { + testWidgets( + 'messages still queue, time out and dismiss (Mornye: $mornye)', + (tester) async { + late BuildContext messageContext; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + home: Builder( + builder: (context) { + messageContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + final first = showAppSnackBar( + messageContext, + content: const Text('First'), + duration: const Duration(seconds: 1), + ); + final second = showAppSnackBar( + messageContext, + content: const Text('Second'), + ); + await tester.pumpAndSettle(); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsNothing); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(await first.closed, SnackBarClosedReason.timeout); + expect(find.text('Second'), findsOneWidget); + await tester.drag(find.text('Second'), const Offset(0, 300)); + await tester.pumpAndSettle(); + expect(await second.closed, SnackBarClosedReason.swipe); + expect(find.text('Second'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/app_switch_test.dart b/test/app_switch_test.dart new file mode 100644 index 00000000..a32b3074 --- /dev/null +++ b/test/app_switch_test.dart @@ -0,0 +1,128 @@ +import 'dart:ui' show SemanticsAction; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +void main() { + Future pumpSwitch( + WidgetTester tester, { + required Widget Function(StateSetter setState) builder, + bool reduceMotion = false, + }) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) => builder(setState), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + } + + testWidgets('glass switch supports tap, keyboard and accessible activation', ( + tester, + ) async { + var value = false; + final changes = []; + await pumpSwitch( + tester, + builder: (setState) => AppSwitch( + value: value, + semanticLabel: 'Example setting', + onChanged: (next) { + changes.add(next); + setState(() => value = next); + }, + ), + ); + + await tester.tap(find.byType(LiquidGlassSwitch)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(changes, [true]); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(changes, [true, false]); + + final node = tester.getSemantics(find.bySemanticsLabel('Example setting')); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(changes, [true, false, true]); + expect(tester.takeException(), isNull); + }); + + testWidgets('disabled glass switch has no toggle action', (tester) async { + await pumpSwitch( + tester, + builder: (_) => const AppSwitch( + value: true, + onChanged: null, + semanticLabel: 'Disabled setting', + ), + ); + final node = tester.getSemantics(find.bySemanticsLabel('Disabled setting')); + expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); + expect(find.byType(LiquidGlassSwitch), findsNothing); + expect(tester.takeException(), isNull); + }); + + for (final grouped in [false, true]) { + testWidgets('row and thumb each toggle once (grouped=$grouped)', ( + tester, + ) async { + var value = false; + final changes = []; + await pumpSwitch( + tester, + reduceMotion: true, + builder: (setState) { + void update(bool next) { + changes.add(next); + setState(() => value = next); + } + + return grouped + ? SettingsGroup( + children: [ + SettingsSwitchItem( + title: 'Example setting', + value: value, + onChanged: update, + ), + ], + ) + : AppSwitchListTile( + title: const Text('Example setting'), + value: value, + onChanged: update, + ); + }, + ); + expect(find.byType(LiquidGlassSwitch), findsNothing); + await tester.tap(find.text('Example setting')); + await tester.pumpAndSettle(); + expect(changes, [true]); + await tester.tap(find.byType(AppSwitch)); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 7adb75a4..ae0a8e3c 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -6,14 +6,17 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/source/line_info.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; @@ -77,8 +80,43 @@ void main() { AppTheme.dark(isAmoled: true).extension(), AppTokens.standard, ); + expect( + MornyeTheme.build(Brightness.light).extension(), + MornyeTheme.tokens, + ); + expect( + MornyeTheme.build(Brightness.dark).colorScheme.primary, + MornyeTheme.darkAccent, + ); }); + testWidgets( + 'Mornye keeps a font family and Cupertino tracking in text roles', + (tester) async { + final theme = MornyeTheme.build(Brightness.light); + final apple = theme.platform == TargetPlatform.iOS; + for (final role in [ + theme.textTheme.bodyLarge, + theme.textTheme.bodyMedium, + theme.textTheme.titleMedium, + theme.textTheme.titleSmall, + theme.textTheme.labelLarge, + theme.textTheme.labelSmall, + ]) { + expect(role?.fontFamily, apple ? 'CupertinoSystemText' : 'Inter'); + expect(role?.letterSpacing, lessThan(0)); + } + expect( + theme.textTheme.headlineLarge?.fontFamily, + apple ? 'CupertinoSystemDisplay' : 'Inter', + ); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + testWidgets('context.tokens falls back to the standard scale', ( tester, ) async { @@ -185,6 +223,87 @@ void main() { }); group('AppSearchField', () { + for (final direction in TextDirection.values) { + testWidgets( + 'glass theme selector preserves logical selection in $direction', + (tester) async { + var selected = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: Scaffold( + body: Directionality( + textDirection: direction, + child: StatefulBuilder( + builder: (context, setState) => MornyeSegmentedControl( + labels: const ['System', 'Light', 'Dark'], + selectedIndex: selected, + onChanged: (index) => setState(() => selected = index), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + final systemX = tester.getCenter(find.text('System').first).dx; + final darkX = tester.getCenter(find.text('Dark').first).dx; + expect(systemX < darkX, direction == TextDirection.ltr); + // The package puts a transparent tap layer above the drawn labels. + await tester.tapAt(tester.getCenter(find.text('Dark').first)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(selected, 2); + await tester.tapAt(tester.getCenter(find.text('System').first)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(selected, 0); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('glass search keeps text editing, submit and clear usable', ( + tester, + ) async { + final controller = TextEditingController(); + final changes = []; + String? submitted; + var cleared = false; + addTearDown(controller.dispose); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Scaffold( + body: AppSearchField( + controller: controller, + hintText: 'Search library', + clearTooltip: 'Clear search', + onChanged: changes.add, + onSubmitted: (value) => submitted = value, + onClear: () => cleared = true, + ), + ), + ), + ), + ); + + expect(find.byType(MornyeGlass), findsOneWidget); + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'Album'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + expect(changes, ['Album']); + expect(submitted, 'Album'); + await tester.tap(find.byTooltip('Clear search')); + await tester.pump(); + expect(controller.text, isEmpty); + expect(cleared, isTrue); + expect(tester.takeException(), isNull); + }); + testWidgets('uses the shared filled search style and clears input', ( tester, ) async { diff --git a/test/download_service_picker_drag_test.dart b/test/download_service_picker_drag_test.dart index 465d0ef9..56a6b3a4 100644 --- a/test/download_service_picker_drag_test.dart +++ b/test/download_service_picker_drag_test.dart @@ -5,12 +5,13 @@ import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; class _Extensions extends ExtensionNotifier { - _Extensions(this.qualityCount); + _Extensions(this._qualityCount); - final int qualityCount; + final int _qualityCount; @override ExtensionState build() => ExtensionState( @@ -25,7 +26,7 @@ class _Extensions extends ExtensionNotifier { status: 'loaded', hasDownloadProvider: true, qualityOptions: List.generate( - qualityCount, + _qualityCount, (index) => QualityOption(id: '$index', label: 'Quality $index'), ), ), @@ -42,64 +43,87 @@ class _Settings extends SettingsNotifier { } void main() { - for (final qualityCount in [3, 16]) { - testWidgets('iOS picker fits $qualityCount options and handle dismisses', ( - tester, - ) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - extensionProvider.overrideWith(() => _Extensions(qualityCount)), - settingsProvider.overrideWith(_Settings.new), - ], - child: MaterialApp( - theme: ThemeData(platform: TargetPlatform.iOS), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () => DownloadServicePicker.show( - context, - trackName: 'Example track', - onSelect: (_, _) {}, + for (final (qualityCount, mornye) in [ + (3, false), + (16, false), + (3, true), + (16, true), + ]) { + testWidgets( + 'picker fits $qualityCount options and dismisses (Mornye=$mornye)', + (tester) async { + tester.view.physicalSize = const Size(320, 780); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + (String, String)? selection; + await tester.pumpWidget( + ProviderScope( + overrides: [ + extensionProvider.overrideWith(() => _Extensions(qualityCount)), + settingsProvider.overrideWith(_Settings.new), + ], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData(platform: TargetPlatform.iOS), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => DownloadServicePicker.show( + context, + trackName: 'Example track', + onSelect: (quality, service) => + selection = (quality, service), + ), + child: const Text('Open'), ), - child: const Text('Open'), ), ), ), ), - ), - ); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - if (qualityCount == 3) { - final sheetBottom = tester.getBottomLeft(find.byType(BottomSheet)).dy; - final lastOptionBottom = tester - .getBottomLeft(find.text('Quality 2')) - .dy; - expect(sheetBottom - lastOptionBottom, lessThan(100)); - } - if (qualityCount == 16) { - final scroll = find.descendant( - of: find.byType(DownloadServicePicker), - matching: find.byType(SingleChildScrollView), ); - await tester.drag(scroll, const Offset(0, -300)); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.byType(DownloadServicePicker), findsOneWidget); - await tester.drag(scroll, const Offset(0, 1500)); + if (qualityCount == 3) { + final sheetBottom = tester.getBottomLeft(find.byType(BottomSheet)).dy; + final lastOptionBottom = tester + .getBottomLeft(find.text('Quality 2')) + .dy; + expect(sheetBottom - lastOptionBottom, lessThan(100)); + } + if (qualityCount == 16) { + final scroll = find.descendant( + of: find.byType(DownloadServicePicker), + matching: find.byType(SingleChildScrollView), + ); + await tester.drag(scroll, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(find.byType(DownloadServicePicker), findsOneWidget); + await tester.drag(scroll, const Offset(0, 1500)); + await tester.pumpAndSettle(); + } + final sheetTop = tester.getTopLeft(find.byType(BottomSheet)); + final sheetWidth = tester.getSize(find.byType(BottomSheet)).width; + await tester.dragFrom( + sheetTop + Offset(sheetWidth / 2, 16), + const Offset(0, 600), + ); await tester.pumpAndSettle(); - } - final sheetTop = tester.getTopLeft(find.byType(BottomSheet)); - final sheetWidth = tester.getSize(find.byType(BottomSheet)).width; - await tester.dragFrom( - sheetTop + Offset(sheetWidth / 2, 16), - const Offset(0, 600), - ); - await tester.pumpAndSettle(); - expect(find.byType(DownloadServicePicker), findsNothing); - expect(tester.takeException(), isNull); - }); + expect(find.byType(DownloadServicePicker), findsNothing); + expect(selection, isNull); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final lastQuality = find.text('Quality ${qualityCount - 1}'); + await tester.ensureVisible(lastQuality); + await tester.pumpAndSettle(); + await tester.tap(lastQuality); + await tester.pumpAndSettle(); + expect(selection, ('${qualityCount - 1}', 'example')); + expect(find.byType(DownloadServicePicker), findsNothing); + expect(tester.takeException(), isNull); + }, + ); } } diff --git a/test/extension_initialization_test.dart b/test/extension_initialization_test.dart index e20145e4..ccd6e6f4 100644 --- a/test/extension_initialization_test.dart +++ b/test/extension_initialization_test.dart @@ -314,6 +314,58 @@ void main() { expect(container.read(repoProvider).error, isNull); }); + for (final hasWorkingExtension in [false, true]) { + test('native package load errors stay visible and recover on retry ' + '(partial=$hasWorkingExtension)', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(extensionProvider.notifier); + var fail = true; + const loadError = + 'example-unreadable: cipher: message authentication failed'; + messenger.setMockMethodCallHandler(backend, (call) async { + switch (call.method) { + case 'loadExtensionsFromDir': + return { + 'loaded': hasWorkingExtension ? ['example-working'] : null, + 'errors': fail ? [loadError] : [], + }; + case 'getInstalledExtensions': + return >[ + if (hasWorkingExtension) + { + 'id': 'example-working', + 'name': 'example-working', + 'display_name': 'Example Working', + 'version': '1.0.0', + 'enabled': false, + }, + ]; + case 'getProviderPriority': + case 'getMetadataProviderPriority': + return []; + default: + return null; + } + }); + + await container.read(settingsProvider.notifier).ensureLoaded(); + final loaded = await notifier.loadExtensions('${root.path}/sources'); + expect(notifier.state.error, contains(loadError)); + expect(loaded, hasWorkingExtension); + expect(notifier.state.isLoading, isFalse); + expect( + notifier.state.extensions.map((extension) => extension.id), + hasWorkingExtension ? ['example-working'] : isEmpty, + ); + + fail = false; + expect(await notifier.loadExtensions('${root.path}/sources'), isTrue); + expect(notifier.state.error, isNull); + }); + } + for (final failureMethod in const [ 'loadExtensionsFromDir', 'getInstalledExtensions', diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index e9cf9690..70f4eae2 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1107,6 +1107,7 @@ void main() { useDynamicColor: false, seedColorValue: 0xff123456, useAmoled: true, + style: AppThemeStyle.mornye, ); final decoded = ThemeSettings.fromJson(settings.toJson()); @@ -1117,10 +1118,15 @@ void main() { expect(decoded.seedColor, const Color(0xff123456)); expect(copied.themeMode, ThemeMode.light); expect(copied.useAmoled, isTrue); + expect(decoded.style, AppThemeStyle.mornye); expect( ThemeSettings.fromJson({'theme_mode': 'invalid'}).themeMode, ThemeMode.system, ); + expect( + ThemeSettings.fromJson({'theme_style': 'invalid'}).style, + AppThemeStyle.material, + ); }); }); diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart new file mode 100644 index 00000000..e582d1f2 --- /dev/null +++ b/test/mornye_bottom_bar_test.dart @@ -0,0 +1,404 @@ +import 'dart:ui' as ui; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/screens/settings/settings_tab.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + late MornyeChromeController chrome; + late ScrollController scroll; + late int searches; + final capture = GlobalKey(); + + setUp(() { + chrome = MornyeChromeController(); + scroll = ScrollController(); + searches = 0; + }); + tearDown(() { + chrome.dispose(); + scroll.dispose(); + }); + + Future pumpShell( + WidgetTester tester, { + bool withPlayer = true, + bool blur = false, + Brightness brightness = Brightness.light, + Color? backdrop, + Color? chromeSurface, + Widget? body, + }) async { + tester.view.physicalSize = const Size(393, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentMediaItemProvider.overrideWith( + (ref) => Stream.value( + withPlayer + ? const MediaItem( + id: 'track', + title: 'A song', + artist: 'Artist', + ) + : null, + ), + ), + playbackStateProvider.overrideWith((ref) => const Stream.empty()), + lowEndDeviceProvider.overrideWithValue(!blur), + backdropBlurEnabledProvider.overrideWithValue(blur), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness, chromeSurface: chromeSurface), + builder: (_, child) => RepaintBoundary(key: capture, child: child), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + backgroundColor: backdrop, + extendBody: true, + body: NotificationListener( + onNotification: chrome.handleScroll, + child: + body ?? + ListView.builder( + controller: scroll, + itemExtent: 60, + itemCount: 50, + itemBuilder: (_, index) => Text('Row $index'), + ), + ), + bottomNavigationBar: ValueListenableBuilder( + valueListenable: chrome, + builder: (_, collapsed, _) => MornyeBottomBar( + collapsed: collapsed, + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination( + icon: Icon(Icons.music_note), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.settings), + label: 'Settings', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + onExpand: chrome.expand, + onSearch: () => searches++, + blurEnabled: blur, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + const albumBlue = Color(0xff464566); + for (final brightness in Brightness.values) { + for (final backdrop in [ + Colors.white, + Colors.black, + if (brightness == Brightness.dark) albumBlue, + ]) { + testWidgets( + 'floating glass stays legible in $brightness over $backdrop', + (tester) async { + await pumpShell( + tester, + blur: true, + brightness: brightness, + backdrop: backdrop, + chromeSurface: backdrop == albumBlue ? albumBlue : null, + ); + final artist = find.text('Artist'); + // The glass renderer also builds a copy for the refractive pill. + final libraryIcon = find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.byIcon(Icons.music_note), + ) + .first; + final artistRect = tester.getRect(artist); + final iconRect = tester.getRect(libraryIcon); + final foregrounds = [ + tester.widget(artist).style!.color!, + IconTheme.of(tester.element(libraryIcon)).color!, + ]; + final samples = [ + Offset(artistRect.left + 8, artistRect.bottom + 2), + Offset(iconRect.right + 8, iconRect.center.dy), + ]; + final backgrounds = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final colors = []; + for (final point in samples) { + final offset = + (point.dy.floor() * image.width + point.dx.floor()) * 4; + colors.add( + Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ), + ); + } + image.dispose(); + return colors; + }); + for (var index = 0; index < foregrounds.length; index++) { + if (backdrop == albumBlue) { + final glass = HSLColor.fromColor(backgrounds![index]); + final page = HSLColor.fromColor(albumBlue); + expect(glass.hue, closeTo(page.hue, 8)); + expect(glass.saturation, greaterThan(page.saturation * 0.7)); + } + final luminances = [ + foregrounds[index].computeLuminance(), + backgrounds![index].computeLuminance(), + ]..sort(); + final contrast = + (luminances.last + 0.05) / (luminances.first + 0.05); + expect( + contrast, + greaterThanOrEqualTo(index == 0 ? 4.5 : 3), + reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon', + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('downward scroll folds the tabs without replacing the player', ( + tester, + ) async { + await pumpShell(tester); + final playerState = tester.state(find.byType(MiniPlayer)); + final expandedHeight = tester.getSize(find.byType(MornyeBottomBar)).height; + await tester.drag(find.byType(ListView), const Offset(0, -240)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + expect(tester.state(find.byType(MiniPlayer)), same(playerState)); + expect(tester.widget(find.byType(MiniPlayer)).compact, isTrue); + expect( + tester.getSize(find.byType(MornyeBottomBar)).height, + lessThan(expandedHeight - 60), + ); + expect(find.byTooltip('Next track'), findsNothing); + await tester.tap(find.byTooltip('Show tabs')); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + expect(tester.getSize(find.byType(MornyeBottomBar)).height, expandedHeight); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'collapse keeps the player and tab contents out of frame rebuilds', + (tester) async { + await pumpShell(tester); + var tabBuilds = 0; + var playerBuilds = 0; + final previous = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = (element, builtOnce) { + previous?.call(element, builtOnce); + if (element.widget is MornyeTabBar) tabBuilds++; + if (element.widget is MiniPlayer) playerBuilds++; + }; + addTearDown(() => debugOnRebuildDirtyWidget = previous); + chrome.value = true; + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect( + tabBuilds, + lessThanOrEqualTo(2), + reason: 'Only the wrapper geometry animates each frame', + ); + expect( + playerBuilds, + lessThanOrEqualTo(2), + reason: + 'The player rebuilds for compact mode, not for every animation tick', + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('folding chrome updates the spacer without rebuilding Settings', ( + tester, + ) async { + await pumpShell(tester, body: const SettingsTab(), blur: true); + var settingsBuilds = 0; + var spacerBuilds = 0; + final previous = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = (element, builtOnce) { + previous?.call(element, builtOnce); + if (element.widget is SettingsTab) settingsBuilds++; + if (element.widget is NavBarSliverSpacer) spacerBuilds++; + }; + addTearDown(() => debugOnRebuildDirtyWidget = previous); + + for (final collapsed in [true, false]) { + chrome.value = collapsed; + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + } + + expect(settingsBuilds, 0); + expect(spacerBuilds, greaterThan(20)); + expect(tester.takeException(), isNull); + }); + + for (final artist in [true, false]) { + testWidgets('folding chrome isolates header insets (artist: $artist)', ( + tester, + ) async { + var pageBuilds = 0; + await pumpShell( + tester, + body: Builder( + builder: (context) { + pageBuilds++; + final tracks = SliverFixedExtentList.builder( + itemExtent: 60, + itemCount: 50, + itemBuilder: (_, index) => Text('Track $index'), + ); + if (artist) { + return CustomScrollView( + controller: scroll, + slivers: [ + ...const MornyeArtistHeader( + name: 'Artist', + showTitle: false, + artwork: ColoredBox(color: Colors.blue), + actions: [], + ).buildSlivers(context), + tracks, + const NavBarSliverSpacer(), + ], + ); + } + return CollectionScaffold( + scrollController: scroll, + isSelectionMode: false, + onExitSelectionMode: () {}, + appBar: const AlbumDetailHeader( + title: 'Album', + expandedHeight: 400, + immersive: true, + showTitleInAppBar: false, + background: ColoredBox(color: Colors.blue), + ), + slivers: [tracks], + ); + }, + ), + ); + pageBuilds = 0; + var collectionBuilds = 0; + final previous = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = (element, builtOnce) { + previous?.call(element, builtOnce); + if (element.widget is CollectionScaffold) collectionBuilds++; + }; + addTearDown(() => debugOnRebuildDirtyWidget = previous); + for (final collapsed in [true, false]) { + chrome.value = collapsed; + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + } + expect(pageBuilds, 0); + expect(collectionBuilds, 0); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'scrolling up expands; programmatic and horizontal movement do not collapse', + (tester) async { + await pumpShell(tester); + scroll.jumpTo(500); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + await tester.drag(find.byType(ListView), const Offset(-120, 0)); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + await tester.drag(find.byType(ListView), const Offset(0, -160)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + await tester.drag(find.byType(ListView), const Offset(0, 90)); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + }, + ); + + testWidgets( + 'glass bar stays mounted and pauses its tickers while collapsed', + (tester) async { + await pumpShell(tester, blur: true); + final glass = tester.element(find.byType(LiquidGlassTabBar)); + final player = tester.state(find.byType(MiniPlayer)); + expect(TickerMode.valuesOf(glass).enabled, isTrue); + chrome.value = true; + await tester.pumpAndSettle(); + expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass)); + expect(TickerMode.valuesOf(glass).enabled, isFalse); + chrome.expand(); + await tester.pumpAndSettle(); + expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass)); + expect(tester.state(find.byType(MiniPlayer)), same(player)); + expect(TickerMode.valuesOf(glass).enabled, isTrue); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('compact navigation works without a playing track', ( + tester, + ) async { + await pumpShell(tester, withPlayer: false); + await tester.drag(find.byType(ListView), const Offset(0, -240)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + await tester.tap(find.byTooltip('Search')); + expect(searches, 1); + await tester.tap(find.byTooltip('Show tabs')); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mornye_collection_sheets_test.dart b/test/mornye_collection_sheets_test.dart new file mode 100644 index 00000000..7e9e75e9 --- /dev/null +++ b/test/mornye_collection_sheets_test.dart @@ -0,0 +1,194 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; + +const _track = Track( + id: 'example-track', + name: 'Example track', + artistName: 'Example artist', + albumName: 'Example album', + duration: 180, +); + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + +class _Collections extends LibraryCollectionsNotifier { + List _summaries = []; + final additions = []; + final createdNames = []; + + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); + + @override + Future createPlaylist(String name) async { + createdNames.add(name); + _summaries = [..._summaries, _playlist(name)]; + ref.invalidate(libraryPlaylistPickerSummariesProvider); + return name; + } + + @override + Future addTracksToPlaylist( + String playlistId, + Iterable tracks, + ) async { + additions.add(playlistId); + return PlaylistAddBatchResult( + addedCount: tracks.length, + alreadyInPlaylistCount: 0, + ); + } +} + +PlaylistPickerSummary _playlist(String name, {bool contains = false}) => + PlaylistPickerSummary( + id: name, + name: name, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + trackCount: 2, + containsAllRequestedTracks: contains, + ); + +Widget _app( + Widget child, + _Collections collections, { + required Brightness brightness, + bool mornye = true, + double textScale = 1, +}) => ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + libraryCollectionsProvider.overrideWith(() => collections), + libraryPlaylistPickerSummariesProvider.overrideWith( + (ref, request) async => collections._summaries, + ), + ], + child: MaterialApp( + theme: mornye ? MornyeTheme.build(brightness) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold(body: child), + ), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + setUpAll(() async { + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont( + rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'), + )) + .load(); + }); + + for (final brightness in Brightness.values) { + testWidgets('track menu reaches playlist creation in $brightness', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 680); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final collections = _Collections(); + await tester.pumpWidget( + _app( + const TrackCollectionQuickActions(track: _track), + collections, + brightness: brightness, + textScale: 1.5, + ), + ); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + expect(find.byType(MornyeGlassPanel), findsOneWidget); + expect(find.byType(ListTile), findsNothing); + expect(find.byIcon(CupertinoIcons.doc_on_doc), findsNWidgets(2)); + + await tester.ensureVisible(find.text('Add to playlist')); + await tester.tap(find.text('Add to playlist')); + await tester.pumpAndSettle(); + expect(find.text('No playlists yet'), findsOneWidget); + expect(find.byType(MornyeGlassPanel), findsOneWidget); + expect(find.text('Done').hitTestable(), findsOneWidget); + + await tester.tap(find.text('Create playlist')); + await tester.pumpAndSettle(); + expect(find.byType(AppAlertDialog), findsOneWidget); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + expect(collections.createdNames, isEmpty); + await tester.enterText(find.byType(TextFormField), 'Evening music'); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + expect(collections.createdNames, ['Evening music']); + expect(collections.additions, ['Evening music']); + expect(find.byIcon(CupertinoIcons.checkmark_circle_fill), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(collections.additions, ['Evening music']); + expect(find.byType(MornyeGlassPanel), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + for (final mornye in [true, false]) { + testWidgets('picker adds only new selections (Mornye: $mornye)', ( + tester, + ) async { + final collections = _Collections() + .._summaries = [ + _playlist('Already included', contains: true), + _playlist('Road trip'), + _playlist('Unselected playlist'), + ]; + await tester.pumpWidget( + _app( + Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => + showAddTracksToPlaylistSheet(context, ref, [_track]), + child: const Text('Open'), + ), + ), + collections, + brightness: Brightness.light, + mornye: mornye, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Already included')); + await tester.tap(find.text('Road trip')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(collections.additions, ['Road trip']); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart new file mode 100644 index 00000000..8f13ca93 --- /dev/null +++ b/test/mornye_context_menu_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future openMenu( + WidgetTester tester, { + required Rect anchor, + required ValueChanged onResult, + Size size = const Size(390, 844), + Brightness brightness = Brightness.light, + double textScale = 1, + bool reduceMotion = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 24), + textScaler: TextScaler.linear(textScale), + disableAnimations: reduceMotion, + ), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () async { + final result = await showMornyeContextMenu( + context: context, + anchor: anchor, + builder: (menuContext) { + MornyeMenuAction action(String label, IconData icon) => + MornyeMenuAction( + icon: icon, + label: label, + onPressed: () => + Navigator.of(menuContext).pop(label), + ); + return MornyeContextMenu( + quickActions: [ + action( + 'Download', + CupertinoIcons.arrow_down_circle, + ), + action('Favorite', CupertinoIcons.star), + action('Share', CupertinoIcons.share), + ], + groups: [ + [ + action('Play', CupertinoIcons.play), + action('Shuffle', CupertinoIcons.shuffle), + ], + [ + action( + 'Add to playlist', + CupertinoIcons.text_badge_plus, + ), + action('Play next', CupertinoIcons.text_insert), + ], + [action('Remove', CupertinoIcons.trash)], + ], + ); + }, + ); + onResult(result); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + for (final brightness in Brightness.values) { + testWidgets('menu stays on screen near a bottom edge ($brightness)', ( + tester, + ) async { + String? result; + const anchor = Rect.fromLTWH(338, 728, 36, 44); + await openMenu( + tester, + anchor: anchor, + brightness: brightness, + onResult: (value) => result = value, + ); + final rect = tester.getRect(find.byType(MornyeContextMenu)); + expect(rect.left, greaterThanOrEqualTo(16)); + expect(rect.right, lessThanOrEqualTo(374)); + expect(rect.top, greaterThanOrEqualTo(40)); + expect(rect.bottom, lessThan(anchor.top)); + expect(find.text('Share').hitTestable(), findsOneWidget); + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + expect(result, 'Share'); + expect(find.byType(MornyeContextMenu), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'large text in landscape keeps shortcuts and last action usable', + (tester) async { + String? result; + await openMenu( + tester, + anchor: const Rect.fromLTWH(600, 200, 24, 44), + size: const Size(640, 320), + textScale: 2.5, + onResult: (value) => result = value, + ); + final rect = tester.getRect(find.byType(MornyeContextMenu)); + expect(rect.top, greaterThanOrEqualTo(40)); + expect(rect.bottom, lessThanOrEqualTo(280)); + expect(find.text('Download').hitTestable(), findsOneWidget); + await tester.ensureVisible(find.text('Remove')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(result, 'Remove'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('outside tap dismisses without selecting in reduced motion', ( + tester, + ) async { + final results = []; + await openMenu( + tester, + anchor: const Rect.fromLTWH(12, 40, 44, 44), + reduceMotion: true, + onResult: results.add, + ); + await tester.tapAt(const Offset(8, 800)); + await tester.pump(); + expect(results, [null]); + expect(find.byType(MornyeContextMenu), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mornye_detail_headers_test.dart b/test/mornye_detail_headers_test.dart new file mode 100644 index 00000000..96643d21 --- /dev/null +++ b/test/mornye_detail_headers_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; + +void main() { + for (final brightness in Brightness.values) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'album actions and pinned navigation work in $brightness at $scale', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.reset); + final controller = ScrollController(); + addTearDown(controller.dispose); + var plays = 0; + var shuffles = 0; + var backs = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: CollectionScaffold( + scrollController: controller, + isSelectionMode: false, + onExitSelectionMode: () {}, + bottomInset: 0, + appBar: AlbumDetailHeader( + immersive: true, + title: + 'A long album title that must wrap without hiding any of its controls', + expandedHeight: 400, + showTitleInAppBar: false, + background: const ColoredBox( + key: ValueKey('album-artwork'), + color: Colors.orange, + ), + coverBuilder: (_, _) => + const ColoredBox(color: Colors.orange), + subtitle: const Text('Artist name'), + leading: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: 'Back', + onPressed: () => backs++, + ), + actions: AlbumPlayActions( + playLabel: 'Play', + shuffleTooltip: 'Shuffle', + onPlay: () => plays++, + onShuffle: () => shuffles++, + ), + ), + slivers: [ + SliverList.builder( + itemCount: 40, + itemBuilder: (_, index) => + SizedBox(height: 64, child: Text('Track $index')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byKey(const ValueKey('album-artwork'))), + const Size(390, 390), + ); + await tester.ensureVisible(find.text('Play')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Play')); + await tester.tap(find.text('Shuffle')); + expect(plays, 1); + expect(shuffles, 1); + controller.jumpTo(1300); + await tester.pumpAndSettle(); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + expect(backs, 1); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets( + 'artist identity grows with text and retains navigation after scroll', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final controller = ScrollController(); + addTearDown(controller.dispose); + var favorites = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Builder( + builder: (context) => Scaffold( + body: CustomScrollView( + controller: controller, + slivers: [ + ...MornyeArtistHeader( + name: 'An artist with a name spanning several lines', + showTitle: true, + artwork: const ColoredBox(color: Colors.orange), + actions: [ + HeaderCircleButton( + icon: Icons.favorite_border, + tooltip: 'Favorite', + onPressed: () => favorites++, + ), + ], + ).buildSlivers(context), + const SliverToBoxAdapter(child: SizedBox(height: 2000)), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Favorite')); + expect(favorites, 1); + controller.jumpTo(1000); + await tester.pumpAndSettle(); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/mornye_library_actions_test.dart b/test/mornye_library_actions_test.dart new file mode 100644 index 00000000..0e026ea1 --- /dev/null +++ b/test/mornye_library_actions_test.dart @@ -0,0 +1,344 @@ +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/services/batch_track_actions.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; + +Widget _app( + Widget child, { + Brightness brightness = Brightness.dark, + TargetPlatform? platform, +}) => ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness).copyWith(platform: platform), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), +); + +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +void _mockMetadataFile(WidgetTester tester) { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + channel, + (call) async => switch (call.method) { + 'safStat' => jsonEncode({'exists': true, 'size': 100}), + 'readAudioMetadata' || + 'readFileMetadata' => jsonEncode({'title': 'Track', 'artist': 'Artist'}), + 'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}), + 'getSafFileModTimes' => '{}', + _ => null, + }, + ); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); +} + +void main() { + setUpAll(() async { + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont( + rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'), + )) + .load(); + }); + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + }); + + testWidgets('Mornye selection actions remain accessible on narrow screens', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 780)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var selectedAll = false; + var closed = false; + var deleted = false; + var converted = false; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => Align( + alignment: Alignment.bottomCenter, + child: SelectionBottomBar( + selectedCount: 1, + allSelected: false, + onClose: () => closed = true, + onToggleSelectAll: () => selectedAll = true, + bottomPadding: 0, + children: [ + SelectionActionButton( + icon: Icons.swap_horiz, + label: 'Convert 1 track', + onPressed: () => converted = true, + colorScheme: Theme.of(context).colorScheme, + ), + const SizedBox(height: 8), + DestructiveSelectionButton( + count: 1, + colorScheme: Theme.of(context).colorScheme, + onPressed: () => deleted = true, + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.text('Select All')); + await _tapVisible(tester, find.text('Convert 1 track')); + await _tapVisible(tester, find.byIcon(CupertinoIcons.trash)); + await _tapVisible(tester, find.byIcon(CupertinoIcons.xmark)); + expect((selectedAll, converted, deleted, closed), (true, true, true, true)); + expect(tester.takeException(), isNull); + }); + + for (final brightness in Brightness.values) { + testWidgets('Mornye conversion preserves selected options ($brightness)', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + (String, String, bool)? result; + await tester.pumpWidget( + _app( + BatchConvertSheet( + formats: const [ + 'ALAC', + 'FLAC', + 'WAV', + 'AIFF', + 'AAC', + 'MP3', + 'Opus', + ], + title: 'Batch Convert', + confirmLabel: 'Convert tracks', + sourceBitDepth: 24, + sourceSampleRate: 48000, + onConvert: (format, bitrate, quality, processing, keepOriginal) { + result = (format, bitrate, keepOriginal); + }, + ), + brightness: brightness, + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.text('AAC')); + await _tapVisible(tester, find.text('128k')); + await _tapVisible(tester, find.byType(AppSwitch)); + await _tapVisible(tester, find.text('Convert tracks')); + expect(result, ('AAC', '128k', true)); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'Mornye Re-enrich retains field choices and disables empty review', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + ReEnrichFieldSelection? result; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => CupertinoButton( + onPressed: () async { + result = await showReEnrichFieldDialog( + context, + selectedCount: 2, + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + await _tapVisible(tester, find.text('Open')); + await _tapVisible(tester, find.text('Update selected tags')); + await _tapVisible(tester, find.text('Select All')); + final review = find.widgetWithText(AppActionButton, 'Review changes'); + expect(tester.widget(review).onPressed, isNull); + await _tapVisible(tester, find.text('Cover Art')); + await _tapVisible(tester, find.text('Review changes')); + expect(result?.mode, ReEnrichBatchMode.selectedFields); + expect(result?.fields, [ReEnrichFields.cover]); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye ReplayGain cancellation restores selection without analysis', + (tester) async { + bool? confirmed; + var exitedSelection = false; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => CupertinoButton( + onPressed: () => runBatchReplayGain( + context, + [ + UnifiedLibraryItem( + id: 'track', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: '/missing/track.flac', + addedAt: DateTime(2026), + source: LibraryItemSource.local, + ), + ], + onExitSelectionMode: () => exitedSelection = true, + onConfirmClosed: (value) async => confirmed = value, + ), + child: const Text('Open'), + ), + ), + ), + ); + await _tapVisible(tester, find.text('Open')); + expect(find.byType(AppAlertDialog), findsOneWidget); + await _tapVisible(tester, find.text('Cancel')); + expect(confirmed, isFalse); + expect(exitedSelection, isFalse); + expect(tester.takeException(), isNull); + }, + ); + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('Metadata menu scroll reaches the last action ($platform)', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 540)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + _mockMetadataFile(tester); + await tester.pumpWidget( + _app( + TrackMetadataScreen( + item: DownloadHistoryItem( + id: 'menu-track', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://library/document/menu-track.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + ), + ), + platform: platform, + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis)); + final lastAction = find.text('Remove from device'); + expect(lastAction.hitTestable(), findsNothing); + expect(find.byType(MornyeContextMenu), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + for (var drag = 0; drag < 4; drag++) { + await tester.dragFrom(const Offset(195, 400), const Offset(0, -320)); + await tester.pumpAndSettle(); + } + expect(find.text('Share').hitTestable(), findsOneWidget); + expect(lastAction.hitTestable(), findsOneWidget); + // Device Hub / Simulator users can also scroll with a trackpad or wheel. + await tester.sendEventToBinding( + const PointerScrollEvent( + position: Offset(195, 400), + scrollDelta: Offset(0, -2000), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Play next').hitTestable(), findsOneWidget); + await tester.sendEventToBinding( + const PointerScrollEvent( + position: Offset(195, 400), + scrollDelta: Offset(0, 2000), + ), + ); + await tester.pumpAndSettle(); + expect(lastAction.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('Mornye metadata editor keeps edited text and Save available', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + _mockMetadataFile(tester); + await tester.pumpWidget( + _app( + TrackMetadataScreen( + item: DownloadHistoryItem( + id: 'edit-track', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://library/document/edit-track.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis)); + await tester.tap(find.text('Edit Metadata')); + await tester.pump(const Duration(milliseconds: 400)); + // The editor extracts its preview through real temporary-file I/O. + await tester.runAsync(() async { + for (var attempt = 0; attempt < 100; attempt++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + if (find.byType(LinearProgressIndicator).evaluate().isEmpty) break; + } + }); + expect(find.byType(LinearProgressIndicator), findsNothing); + await tester.pumpAndSettle(); + final title = find.byWidgetPredicate( + (widget) => + widget is CupertinoTextField && widget.controller?.text == 'Track', + ); + await tester.ensureVisible(title); + await tester.enterText(title, 'Edited track'); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + expect(find.text('Edited track'), findsOneWidget); + expect(find.widgetWithText(AppActionButton, 'Save'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mornye_loading_and_chrome_test.dart b/test/mornye_loading_and_chrome_test.dart new file mode 100644 index 00000000..35cef881 --- /dev/null +++ b/test/mornye_loading_and_chrome_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('Mornye loading layouts fit a narrow screen in $brightness', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + for (final skeleton in const [ + ArtistScreenSkeleton(showCoverHeader: false), + AlbumTrackListSkeleton(showCoverHeader: true), + TrackListSkeleton(showCoverHeader: true), + ]) { + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(brightness), + home: Scaffold(body: skeleton), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + await tester.drag( + find.byType(SingleChildScrollView).first, + const Offset(0, -350), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets( + 'chrome follows the visible artist and restores for albums and tabs', + (tester) async { + final key = ShellNavigationService.homeTabNavigatorKey; + final observer = ShellChromeObserver(key); + ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: true); + addTearDown(observer.detach); + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: Navigator( + key: key, + observers: [observer], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Home')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + key.currentState!.push( + MaterialPageRoute( + builder: (_) => const MornyeArtistSurface( + imageSource: null, + child: Scaffold(body: Text('Artist')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + final artistSurface = Theme.of( + tester.element(find.text('Artist')), + ).colorScheme.surface; + expect(ShellNavigationService.chromeSurface.value, artistSurface); + + // Menus do not turn the bars light over a dark artist. + showDialog( + context: tester.element(find.text('Artist')), + useRootNavigator: false, + builder: (_) => const AlertDialog(title: Text('Options')), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + key.currentState!.pop(); + await tester.pumpAndSettle(); + + // The mounted artist under a pushed album must not keep a stale override. + key.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Album')), + ), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + expect(ShellNavigationService.chromeSurface.value, isNull); + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + expect(ShellNavigationService.chromeSurface.value, artistSurface); + + ShellNavigationService.syncState(currentTabIndex: 3, showRepoTab: true); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + expect(ShellNavigationService.chromeSurface.value, isNull); + ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: true); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +} diff --git a/test/mornye_playback_button_test.dart b/test/mornye_playback_button_test.dart new file mode 100644 index 00000000..c2df633e --- /dev/null +++ b/test/mornye_playback_button_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; + +void main() { + Future pumpButton( + WidgetTester tester, { + required VoidCallback onPressed, + IconData icon = CupertinoIcons.play_fill, + bool reduceMotion = false, + }) => tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Center( + child: MornyePlaybackButton( + icon: icon, + tooltip: 'Playback action', + color: Colors.white, + onPressed: onPressed, + ), + ), + ), + ), + ), + ); + + testWidgets('press feedback keeps the tap target fixed and cancels safely', ( + tester, + ) async { + var calls = 0; + await pumpButton(tester, onPressed: () => calls++); + final button = find.byType(IconButton); + final icon = find.byIcon(CupertinoIcons.play_fill); + final bounds = tester.getRect(button); + final iconBounds = tester.getRect(icon); + final press = await tester.startGesture(bounds.center); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getRect(icon).width, lessThan(iconBounds.width)); + expect(tester.getRect(button), bounds); + await press.up(); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(tester.getRect(icon), iconBounds); + + final cancelled = await tester.startGesture(bounds.center); + await tester.pump(const Duration(milliseconds: 100)); + await cancelled.cancel(); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(tester.getRect(icon), iconBounds); + }); + + for (final icon in [ + CupertinoIcons.forward_fill, + CupertinoIcons.backward_fill, + ]) { + testWidgets( + 'skip $icon animates without delaying repeated playback actions', + (tester) async { + var calls = 0; + await pumpButton(tester, icon: icon, onPressed: () => calls++); + final button = find.byType(IconButton); + final bounds = tester.getRect(button); + for (var index = 0; index < 3; index++) { + await tester.tap(button); + await tester.pump(const Duration(milliseconds: 60)); + expect(calls, index + 1); + expect(find.byIcon(CupertinoIcons.play_fill), findsNWidgets(3)); + expect(tester.getRect(button), bounds); + } + await tester.pumpAndSettle(); + expect(find.byIcon(icon), findsOneWidget); + expect(find.byIcon(CupertinoIcons.play_fill), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('Reduce Motion keeps skip glyphs stationary and actions usable', ( + tester, + ) async { + var calls = 0; + await pumpButton( + tester, + icon: CupertinoIcons.forward_fill, + reduceMotion: true, + onPressed: () => calls++, + ); + final icon = find.byIcon(CupertinoIcons.forward_fill); + final bounds = tester.getRect(icon); + final press = await tester.startGesture(tester.getCenter(icon)); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getRect(icon), bounds); + await press.up(); + await tester.pump(const Duration(milliseconds: 60)); + expect(calls, 1); + expect(find.byIcon(CupertinoIcons.play_fill), findsNothing); + expect(tester.getRect(icon), bounds); + await tester.pumpAndSettle(); + }); +} diff --git a/test/mornye_playback_time_test.dart b/test/mornye_playback_time_test.dart new file mode 100644 index 00000000..0e5b2782 --- /dev/null +++ b/test/mornye_playback_time_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; + +Widget _app(int seconds, {bool remaining = false, bool reduceMotion = false}) => + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: MornyePlaybackTime( + seconds: seconds, + remaining: remaining, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ); + +void main() { + for (final increasing in [true, false]) { + testWidgets( + 'Time digits roll ${increasing ? 'up' : 'down'} without drift', + (tester) async { + final oldDigit = increasing ? '4' : '5'; + final newDigit = increasing ? '5' : '4'; + await tester.pumpWidget(_app(increasing ? 34 : 35)); + final oldPosition = tester.getTopLeft(find.text(oldDigit)); + final colonPosition = tester.getTopLeft(find.text(':')); + final initialSize = tester.getSize(find.byType(MornyePlaybackTime)); + + await tester.pumpWidget(_app(increasing ? 35 : 34)); + await tester.pump(const Duration(milliseconds: 80)); + final oldY = tester.getTopLeft(find.text(oldDigit)).dy; + final newY = tester.getTopLeft(find.text(newDigit)).dy; + expect( + oldY, + increasing ? lessThan(oldPosition.dy) : greaterThan(oldPosition.dy), + ); + expect( + newY, + increasing ? greaterThan(oldPosition.dy) : lessThan(oldPosition.dy), + ); + expect(tester.getTopLeft(find.text(':')), colonPosition); + expect(tester.getSize(find.byType(MornyePlaybackTime)), initialSize); + + await tester.pumpAndSettle(); + expect(find.text(oldDigit), findsNothing); + expect(tester.getTopLeft(find.text(newDigit)), oldPosition); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('Minute rollover and rapid seeks expose only the current time', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(_app(599, remaining: true)); + final colonPosition = tester.getTopLeft(find.text(':')); + await tester.pumpWidget(_app(600, remaining: true)); + await tester.pump(const Duration(milliseconds: 80)); + expect(find.bySemanticsLabel('-10:00'), findsOneWidget); + expect(find.bySemanticsLabel('-9:59'), findsNothing); + expect(find.text('-'), findsOneWidget); + expect(tester.getTopLeft(find.text(':')), colonPosition); + + await tester.pumpWidget(_app(598, remaining: true)); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('-9:58'), findsOneWidget); + expect(find.text('0'), findsNothing); + expect(find.text('-'), findsOneWidget); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); + + testWidgets('Reduce Motion updates time without rolling digits', ( + tester, + ) async { + await tester.pumpWidget(_app(34)); + await tester.pumpWidget(_app(35)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.pumpWidget(_app(36, reduceMotion: true)); + expect(find.text('0:36'), findsOneWidget); + expect(find.byType(AnimatedSwitcher), findsNothing); + expect(tester.hasRunningAnimations, isFalse); + }); +} diff --git a/test/mornye_player_queue_test.dart b/test/mornye_player_queue_test.dart new file mode 100644 index 00000000..3c5505e5 --- /dev/null +++ b/test/mornye_player_queue_test.dart @@ -0,0 +1,97 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; + +class _Player extends MusicPlayerController { + int? jumpedTo; + (int, int)? moved; + bool? shuffled; + AudioServiceRepeatMode? repeat; + + @override + Future jumpTo(int index) async => jumpedTo = index; + @override + void moveQueueItem(int oldIndex, int newIndex) => + moved = (oldIndex, newIndex); + @override + Future setShuffle(bool enabled) async => shuffled = enabled; + @override + Future setRepeatMode(AudioServiceRepeatMode mode) async => + repeat = mode; +} + +void main() { + testWidgets( + 'upcoming queue maps taps and reorders to the complete playback queue', + (tester) async { + final player = _Player(); + var libraryShuffles = 0; + final queue = [ + for (final name in ['Previous', 'Current', 'Next', 'Last']) + MediaItem(id: name, title: name, artist: 'Artist'), + ]; + final theme = MornyeTheme.build(Brightness.dark); + await tester.pumpWidget( + ProviderScope( + overrides: [ + musicPlayerControllerProvider.overrideWithValue(player), + currentMediaItemProvider.overrideWith( + (ref) => Stream.value(queue[1]), + ), + playQueueProvider.overrideWith((ref) => Stream.value(queue)), + playbackStateProvider.overrideWith( + (ref) => Stream.value(PlaybackState(queueIndex: 1)), + ), + ], + child: MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MornyePlayerQueue( + colorScheme: theme.colorScheme, + onShuffleLibrary: () => libraryShuffles++, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Previous'), findsNothing); + expect(find.text('Current'), findsNothing); + await tester.tap(find.text('Last')); + expect(player.jumpedTo, 3); + final firstHandle = find.byType(ReorderableDragStartListener).first; + final drag = await tester.startGesture(tester.getCenter(firstHandle)); + await tester.pump(const Duration(milliseconds: 100)); + await drag.moveBy(const Offset(0, 30)); + await tester.pump(); + await drag.moveBy(const Offset(0, 180)); + await tester.pump(const Duration(milliseconds: 300)); + await drag.up(); + await tester.pumpAndSettle(); + expect(player.moved, (2, 3)); + await tester.tap(find.byIcon(CupertinoIcons.shuffle)); + expect(player.shuffled, isTrue); + await tester.tap(find.byIcon(CupertinoIcons.repeat)); + expect(player.repeat, AudioServiceRepeatMode.all); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + final shuffleLibrary = AppLocalizations.of( + tester.element(find.byType(MornyePlayerQueue)), + ).nowPlayingShuffleLibrary; + await tester.tap(find.text(shuffleLibrary)); + await tester.pumpAndSettle(); + expect(libraryShuffles, 1); + expect(find.text(shuffleLibrary), findsNothing); + expect(find.text('Next'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/mornye_search_glass_test.dart b/test/mornye_search_glass_test.dart new file mode 100644 index 00000000..431d4cee --- /dev/null +++ b/test/mornye_search_glass_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets( + 'search glass cards scroll and filters remain usable in $brightness', + (tester) async { + var selected = false; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => Column( + children: [ + MornyeGlassPanel.overlay( + radius: 24, + child: MornyeFilterChip( + label: 'Albums', + selected: selected, + glass: false, + tonal: selected, + onTap: () => setState(() => selected = !selected), + ), + ), + Expanded( + child: ListView.builder( + itemCount: 12, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MornyeGlassPanel.overlay( + radius: 24, + child: SizedBox( + height: 88, + child: Text('Result $index'), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Albums')); + await tester.pumpAndSettle(); + expect(selected, isTrue); + await tester.scrollUntilVisible(find.text('Result 11'), 200); + await tester.pumpAndSettle(); + expect(find.text('Result 11').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/mornye_volume_control_test.dart b/test/mornye_volume_control_test.dart new file mode 100644 index 00000000..3683239f --- /dev/null +++ b/test/mornye_volume_control_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('In-app volume writes suppress the system volume panel', () async { + const channel = MethodChannel('com.kurenai7968.volume_controller.method'); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + channel, + (call) async => calls.add(call), + ); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(systemVolumeWriterProvider)(0.7); + expect(calls.single.method, 'setVolume'); + expect(calls.single.arguments, {'volume': 0.7, 'showSystemUI': false}); + }); + + testWidgets('Slow volume writes coalesce and cannot reset an active drag', ( + tester, + ) async { + final writes = []; + final completions = >[]; + final volumes = StreamController(); + addTearDown(volumes.close); + await tester.pumpWidget( + ProviderScope( + overrides: [ + systemVolumeProvider.overrideWith((ref) => volumes.stream), + systemVolumeWriterProvider.overrideWith( + (ref) => (value) { + writes.add(value); + final completion = Completer(); + completions.add(completion); + return completion.future; + }, + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MornyeVolumeControl()), + ), + ), + ); + volumes.add(0.5); + await tester.pumpAndSettle(); + Slider slider() => tester.widget(find.byType(Slider)); + + slider().onChangeStart!(0.5); + slider().onChanged!(0.6); + slider().onChanged!(0.7); + slider().onChanged!(0.8); + await tester.pump(); + expect(writes, [0.6]); + expect(slider().value, 0.8); + + completions.first.complete(); + await tester.pump(); + expect(writes, [0.6, 0.8]); + volumes.add(0.6); + completions.last.complete(); + await tester.pump(); + expect(slider().value, 0.8); + + slider().onChangeEnd!(0.8); + slider().onChangeStart!(0.8); + slider().onChanged!(0.9); + completions.last.complete(); + await tester.pump(); + expect(writes.last, 0.9); + completions.last.complete(); + await tester.pump(); + expect(slider().value, 0.9); + + slider().onChangeEnd!(0.9); + volumes.add(0.9); + completions.last.complete(); + await tester.pump(); + volumes.add(0.3); + await tester.pumpAndSettle(); + expect(slider().value, 0.3); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart index 509080d4..2e8a6848 100644 --- a/test/motion_header_banner_test.dart +++ b/test/motion_header_banner_test.dart @@ -6,6 +6,8 @@ import 'package:video_player_platform_interface/video_player_platform_interface. class _VideoPlatform extends VideoPlayerPlatform { bool playing = false; int playCalls = 0; + bool looping = false; + double volume = 1; @override Future init() async {} @@ -32,10 +34,12 @@ class _VideoPlatform extends VideoPlayerPlatform { Future pause(int playerId) async => playing = false; @override - Future setLooping(int playerId, bool looping) async {} + Future setLooping(int playerId, bool looping) async => + this.looping = looping; @override - Future setVolume(int playerId, double volume) async {} + Future setVolume(int playerId, double volume) async => + this.volume = volume; @override Future setPlaybackSpeed(int playerId, double speed) async {} @@ -91,6 +95,8 @@ void main() { await tester.pumpWidget(app()); await tester.pump(); expect(platform.playing, isTrue); + expect(platform.looping, isTrue); + expect(platform.volume, 0); final initialPlayCalls = platform.playCalls; scroll.jumpTo(50); diff --git a/test/music_player_media_metadata_test.dart b/test/music_player_media_metadata_test.dart index f139eba3..71bfa905 100644 --- a/test/music_player_media_metadata_test.dart +++ b/test/music_player_media_metadata_test.dart @@ -37,6 +37,65 @@ void main() { expect(restored.explicit, isTrue); }); + test( + 'restored iOS artwork follows the container without changing external audio', + () { + const previous = + '/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111'; + const current = + '/var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222'; + const externalAudio = + '/var/mobile/Containers/Data/Application/33333333-3333-4333-8333-333333333333/Documents/track.flac'; + final saved = { + ...media.toJson(), + 'source': externalAudio, + 'artUri': Uri.file( + '$previous/Library/Caches/covers/曲 #cover.jpg', + ).toString(), + }; + final restored = PlayableMedia.fromJson( + saved, + iosDocumentsPath: '$current/Documents', + )!; + expect(restored.source, externalAudio); + expect(restored.id, media.id); + expect( + restored.artUri, + Uri.file('$current/Library/Caches/covers/曲 #cover.jpg').toString(), + ); + expect( + restored.toMediaItem().artUri?.toFilePath(), + '$current/Library/Caches/covers/曲 #cover.jpg', + ); + expect( + PlayableMedia.fromJson( + restored.toJson(), + iosDocumentsPath: '$current/Documents', + )!.artUri, + restored.artUri, + ); + }, + ); + + test('artwork restore preserves network, Android and external artwork', () { + const documents = + '/var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents'; + for (final artwork in [ + null, + 'https://example.com/cover.jpg', + 'content://media/covers/123', + 'file:///data/user/0/example/cache/cover.jpg', + 'file:///private/var/mobile/Library/Mobile%20Documents/cover.jpg', + ]) { + final saved = {...media.toJson(), 'artUri': artwork}; + expect( + PlayableMedia.fromJson(saved, iosDocumentsPath: documents)!.artUri, + artwork, + ); + expect(PlayableMedia.fromJson(saved)!.artUri, artwork); + } + }); + test('file probe cannot erase valid queue quality with empty values', () { final merged = mergePlaybackFileMetadata( playbackAudioMetadataFromMediaItem(media.toMediaItem()), diff --git a/test/nav_bar_inset_test.dart b/test/nav_bar_inset_test.dart new file mode 100644 index 00000000..bbd66413 --- /dev/null +++ b/test/nav_bar_inset_test.dart @@ -0,0 +1,110 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('localized inset preserves collection pixels in $brightness', ( + tester, + ) async { + final capture = GlobalKey(); + final inset = ValueNotifier(140.0); + final scroll = ScrollController(); + addTearDown(inset.dispose); + addTearDown(scroll.dispose); + + Future pumpPage({required bool explicitInset}) async { + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(brightness), + home: ValueListenableBuilder( + valueListenable: inset, + builder: (context, value, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: EdgeInsets.only(top: 24, bottom: value)), + child: child!, + ), + child: RepaintBoundary( + key: capture, + child: Builder( + builder: (context) => CollectionScaffold( + scrollController: scroll, + isSelectionMode: false, + onExitSelectionMode: () {}, + bottomInset: explicitInset + ? context.navBarBottomInset + : null, + appBar: const SliverToBoxAdapter( + child: SizedBox(height: 120, child: Text('Collection')), + ), + slivers: [ + SliverFixedExtentList.builder( + itemExtent: 56, + itemCount: 30, + itemBuilder: (_, index) => ListTile( + leading: const Icon(Icons.music_note), + title: Text('Track $index'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future pixels() async { + return (await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + try { + final data = await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + return Uint8List.fromList(data!.buffer.asUint8List()); + } finally { + image.dispose(); + } + }))!; + } + + // Compare the previous, parent-owned inset with the localized spacer at + // intermediate animation heights, including the final scroll position. + final expected = {}; + final extents = {}; + await pumpPage(explicitInset: true); + for (final height in [140.0, 118.0, 86.0, 64.0, 0.0]) { + inset.value = height; + await tester.pumpAndSettle(); + scroll.jumpTo(scroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expected[height] = await pixels(); + extents[height] = scroll.position.maxScrollExtent; + } + + await pumpPage(explicitInset: false); + for (final height in expected.keys) { + inset.value = height; + await tester.pumpAndSettle(); + scroll.jumpTo(scroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(scroll.position.maxScrollExtent, extents[height]); + expect(await pixels(), orderedEquals(expected[height]!)); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 3418f493..dc2f7bc6 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1,22 +1,49 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ui' show ImageFilter; +import 'dart:ui' as ui; import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; +import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart'; +import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_player_background.dart'; +import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); const backendChannel = MethodChannel('com.zarz.spotiflac/backend'); late StreamController mediaItems; + late List volumeWrites; + late Map metadataOverrides; + late List metadataReads; setUp(() { + SharedPreferences.setMockInitialValues({}); mediaItems = StreamController.broadcast(); + volumeWrites = []; + metadataOverrides = {}; + metadataReads = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(backendChannel, (call) async { if (call.method != 'readFileMetadata') { @@ -24,14 +51,18 @@ void main() { } final arguments = (call.arguments as Map).cast(); final path = arguments['file_path']?.toString() ?? ''; + metadataReads.add(path); final lyrics = path.endsWith('/timed.flac') ? '''

Short

''' + : path.endsWith('/many.flac') + ? '[00:00.00]First line\n[00:02.00]Second line\n[00:04.00]Third line\n[00:06.00]Fourth line' : path.endsWith('/second.flac') ? '[00:01.00]Second lyric' : '[00:01.00]First lyric'; return jsonEncode({ 'title': path.endsWith('/second.flac') ? 'Second' : 'First', 'lyrics': lyrics, + ...metadataOverrides, }); }); }); @@ -51,8 +82,15 @@ void main() { extras: {'source': 'content://library/$id.flac'}, ); - Future pumpNowPlaying(WidgetTester tester) async { - tester.view.physicalSize = const Size(1080, 1920); + Future pumpNowPlaying( + WidgetTester tester, { + ThemeData? theme, + Size size = const Size(1080, 1920), + PlaybackState? playback, + Stream? playbackEvents, + Widget Function(Widget)? wrapPlayer, + }) async { + tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -61,18 +99,843 @@ void main() { ProviderScope( overrides: [ currentMediaItemProvider.overrideWith((ref) => mediaItems.stream), - playbackStateProvider.overrideWith((ref) => const Stream.empty()), + playerMotionArtworkProvider.overrideWith((ref, album) async => null), + playerCollectionTrackProvider.overrideWith( + (ref, item) async => Track( + id: item.id, + name: item.title, + artistName: item.artist ?? '', + albumName: item.album ?? '', + duration: item.duration?.inSeconds ?? 0, + source: 'local', + ), + ), + libraryCollectionsProvider.overrideWith(_TestCollections.new), + playbackStateProvider.overrideWith( + (ref) => + playbackEvents ?? + (playback == null + ? const Stream.empty() + : Stream.value(playback)), + ), playQueueProvider.overrideWith((ref) => const Stream.empty()), + systemVolumeProvider.overrideWith((ref) => Stream.value(0.5)), + systemVolumeWriterProvider.overrideWith( + (ref) => + (value) async => volumeWrites.add(value), + ), ], - child: const MaterialApp( + child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: NowPlayingScreen(), + theme: theme, + home: + wrapPlayer?.call(const NowPlayingScreen()) ?? + const NowPlayingScreen(), ), ), ); } + testWidgets('Mornye player background does not reveal the page below', ( + tester, + ) async { + final background = ValueNotifier(Colors.white); + addTearDown(background.dispose); + final capture = GlobalKey(); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + wrapPlayer: (player) => RepaintBoundary( + key: capture, + child: ValueListenableBuilder( + valueListenable: background, + child: player, + builder: (context, color, child) => Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: color), + child!, + ], + ), + ), + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + Future?> edgePixel() => tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final offset = (image.width + 1) * 4; + final pixel = List.generate(4, (i) => bytes.getUint8(offset + i)); + image.dispose(); + return pixel; + }); + + final onWhite = await edgePixel(); + background.value = Colors.red; + await tester.pumpAndSettle(); + expect(await edgePixel(), onWhite); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye player menu floats above its button and retains dark colors', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + final panel = find.byType(MornyePlayerActionsSheet); + final enteringWidth = tester.getSize(panel).width; + await tester.pumpAndSettle(); + expect(tester.getSize(panel).width, enteringWidth); + expect(tester.getRect(panel).bottom, lessThan(780 - 16)); + expect(tester.getRect(panel).width, 320); + expect(find.byType(BottomSheet), findsNothing); + expect(Theme.of(tester.element(panel)).brightness, Brightness.dark); + expect( + tester.widget(find.text('Go to Album')).style?.color, + Colors.white, + ); + expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget); + expect(find.byIcon(CupertinoIcons.moon_zzz), findsOneWidget); + expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing); + await tester.tap(find.text('Sleep timer')); + await tester.pumpAndSettle(); + expect( + Theme.of(tester.element(find.text('15 minutes'))).brightness, + Brightness.dark, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye title opens artist and album destinations with subtitles', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.text('First').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Go to Artist'), findsOneWidget); + expect(find.text('Go to Album'), findsOneWidget); + expect( + find.descendant( + of: find.byType(MornyePlayerNavigationMenu), + matching: find.text('Artist'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(MornyePlayerNavigationMenu), + matching: find.text('Album'), + ), + findsOneWidget, + ); + await tester.tapAt(const Offset(5, 770)); + await tester.pumpAndSettle(); + expect(find.byType(MornyePlayerNavigationMenu), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Android Details reads full tags from a restored content URI', ( + tester, + ) async { + metadataOverrides = { + 'album_artist': 'Album Artist', + 'genre': 'Pop/Rock', + 'composer': 'Composer Name', + 'date': '2025-04-16', + 'track_number': 4, + }; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build( + Brightness.dark, + ).copyWith(platform: TargetPlatform.android), + size: const Size(393, 852), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + expect(metadataReads, isEmpty); + + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(metadataReads, ['content://library/first.flac']); + final details = tester.widget( + find.byType(MornyePlayerDetailsSheet), + ); + expect( + details.rows, + containsAll([ + ('Title', 'First'), + ('Album', 'Album'), + ('Album Artist', 'Album Artist'), + ('Genre', 'Pop/Rock'), + ('Composer', 'Composer Name'), + ('Date', '2025-04-16'), + ('Track #', '4'), + ]), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye Details keeps long values readable and Done visible while scrolling', + (tester) async { + final rights = List.filled(10, 'A long copyright value').join(' '); + metadataOverrides = { + 'title': 'First', + 'artist': 'Artist', + 'album': 'Album', + 'genre': 'Rock', + 'composer': 'Composer', + 'isrc': 'USAAA2600001', + 'copyright': rights, + 'format': 'flac', + 'sample_rate': 96000, + 'bit_depth': 24, + }; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add( + item('first').copyWith(extras: {'source': '/library/first.flac'}), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + final sheet = find.byType(MornyePlayerDetailsSheet); + expect(sheet, findsOneWidget); + expect(Theme.of(tester.element(sheet)).brightness, Brightness.dark); + expect( + find.descendant(of: sheet, matching: find.byType(Card)), + findsNothing, + ); + expect( + find.descendant(of: sheet, matching: find.byType(MornyeGlass)), + findsOneWidget, + ); + final titleRow = find.widgetWithText(MornyeMetadataRow, 'Title'); + expect( + tester + .getTopLeft( + find.descendant(of: titleRow, matching: find.text('First')), + ) + .dy, + greaterThan(tester.getBottomLeft(find.text('Title')).dy), + ); + await tester.scrollUntilVisible( + find.text(rights), + 200, + scrollable: find.descendant( + of: sheet, + matching: find.byType(Scrollable), + ), + ); + expect(tester.getSize(find.text(rights)).height, greaterThan(40)); + expect(find.text('Done').hitTestable(), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(sheet, findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye star shares Library favorites across tracks and compact headers', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(NowPlayingScreen)), + ); + final star = find.byIcon(CupertinoIcons.star).hitTestable(); + expect(star, findsOneWidget); + await tester.tap(star); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.star_fill).hitTestable(), + findsOneWidget, + ); + expect( + container.read(libraryCollectionsProvider).loved.single.track.id, + 'first', + ); + await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.star_fill).hitTestable(), + findsOneWidget, + ); + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.star_fill).hitTestable(), + findsOneWidget, + ); + + // A change from Library must also update the player, without reopening it. + final track = container + .read(libraryCollectionsProvider) + .loved + .single + .track; + await container + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget); + expect( + find.byType(MornyePlayerFavoriteButton).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Mornye lyrics stay blurred until playback starts', ( + tester, + ) async { + final playback = StreamController(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 780), + playbackEvents: playback.stream, + ); + mediaItems.add(item('many')); + playback.add( + PlaybackState( + processingState: AudioProcessingState.loading, + playing: true, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + void expectLine(String text, {required bool active}) { + final line = find.text(text); + final filter = tester.widget( + find.ancestor(of: line, matching: find.byType(ImageFiltered)).first, + ); + final opacity = tester.widget( + find.ancestor(of: line, matching: find.byType(AnimatedOpacity)).first, + ); + expect(opacity.opacity, active ? 1 : 0.48); + expect(filter.enabled, !active, reason: '$text: ${filter.imageFilter}'); + } + + expectLine('First line', active: false); + expectLine('Second line', active: false); + expectLine('Third line', active: false); + + playback.add(PlaybackState(processingState: AudioProcessingState.ready)); + await tester.pumpAndSettle(); + expectLine('First line', active: false); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: true, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pump(); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 160)); + } + expectLine('First line', active: false); + expectLine('Second line', active: true); + expectLine('Third line', active: false); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pumpAndSettle(); + expectLine('Second line', active: true); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.buffering, + playing: true, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pump(); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 160)); + } + expectLine('First line', active: false); + expectLine('Second line', active: false); + expectLine('Third line', active: false); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye lyrics keep the active line sharp with bold leading-aligned text', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + playback: PlaybackState(updatePosition: const Duration(seconds: 2)), + ); + mediaItems.add(item('many')); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + final active = find.text('Second line'); + final inactive = find.text('Third line'); + final activeText = tester.widget(active); + expect(activeText.textAlign, TextAlign.start); + expect(activeText.style?.fontWeight, FontWeight.bold); + expect(activeText.style?.fontSize, 28); + expect(tester.getTopLeft(active).dx, tester.getTopLeft(inactive).dx); + expect( + tester.getTopLeft(active).dx, + tester.getTopLeft(find.byType(ListView)).dx + 24, + ); + final activeFilters = tester.widgetList( + find.ancestor(of: active, matching: find.byType(ImageFiltered)), + ); + expect(activeFilters.any((filter) => !filter.enabled), isTrue); + final inactiveFilters = tester.widgetList( + find.ancestor(of: inactive, matching: find.byType(ImageFiltered)), + ); + expect( + inactiveFilters.any( + (filter) => + filter.enabled && + filter.imageFilter == ImageFilter.blur(sigmaX: 0.6, sigmaY: 0.6), + ), + isTrue, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye queue replaces artwork while volume and playback stay in place', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + final volume = tester.getRect(find.byType(MornyeVolumeControl)); + await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(find.byType(MornyePlayerQueue), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + expect(tester.takeException(), isNull); + await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); + await tester.pump(); + expect(find.text('First lyric'), findsNothing); + await tester.pump(const Duration(milliseconds: 120)); + expect(find.text('First lyric'), findsNothing); + expect(find.byType(MornyePlayerQueue), findsOneWidget); + expect(find.byType(MornyePlayerQueue).hitTestable(), findsNothing); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(find.byType(MornyePlayerQueue), findsNothing); + expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + }, + ); + + testWidgets('Mornye player renders Apple-style transport controls', ( + tester, + ) async { + await pumpNowPlaying(tester, theme: MornyeTheme.build(Brightness.light)); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.play_fill), findsOneWidget); + expect(find.byIcon(CupertinoIcons.backward_fill), findsOneWidget); + expect(find.byIcon(CupertinoIcons.forward_fill), findsOneWidget); + expect(find.byType(MornyeVolumeControl), findsOneWidget); + expect(tester.takeException(), isNull); + + final nextButton = find.widgetWithIcon( + MornyePlaybackButton, + CupertinoIcons.forward_fill, + ); + final nextState = tester.state(nextButton); + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(tester.state(nextButton), same(nextState)); + }); + + testWidgets('Mornye elapsed and remaining times roll on the same second', ( + tester, + ) async { + final playbackEvents = StreamController(); + addTearDown(playbackEvents.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 780), + playbackEvents: playbackEvents.stream, + ); + mediaItems.add( + item( + 'first', + ).copyWith(duration: const Duration(seconds: 180, milliseconds: 600)), + ); + + Future positionAt(int milliseconds) async { + playbackEvents.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: Duration(milliseconds: milliseconds), + ), + ); + await tester.pump(); + await tester.pump(); + } + + final elapsed = find.byKey(const ValueKey('elapsed:first')); + final remaining = find.byKey(const ValueKey('remaining:first')); + void expectTimes(int elapsedSeconds, int remainingSeconds) { + expect( + tester.widget(elapsed).seconds, + elapsedSeconds, + ); + expect( + tester.widget(remaining).seconds, + remainingSeconds, + ); + } + + await positionAt(34200); + await tester.pumpAndSettle(); + expectTimes(34, 146); + await positionAt(34800); + expectTimes(34, 146); + + await positionAt(35000); + await tester.pump(const Duration(milliseconds: 80)); + expectTimes(35, 145); + double incomingOffset(Finder label) => tester + .widget( + find + .ancestor( + of: find.descendant(of: label, matching: find.text('5')), + matching: find.byType(SlideTransition), + ) + .first, + ) + .position + .value + .dy; + expect(incomingOffset(elapsed), greaterThan(0)); + expect(incomingOffset(remaining), -incomingOffset(elapsed)); + + // Another position report within this second must not restart either roll. + await positionAt(35200); + await tester.pump(const Duration(milliseconds: 200)); + expect(incomingOffset(elapsed), 0); + expect(incomingOffset(remaining), 0); + await positionAt(35900); + expectTimes(35, 145); + await positionAt(36000); + expectTimes(36, 144); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Mornye volume stays above the bottom actions on a phone', ( + tester, + ) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 700), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + final volume = find.byType(MornyeVolumeControl); + final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); + final slider = find.descendant(of: volume, matching: find.byType(Slider)); + expect( + tester.getRect(volume).bottom, + lessThan(tester.getRect(lyricsButton).top), + ); + expect(tester.getSize(slider).height, greaterThanOrEqualTo(44)); + final gesture = await tester.startGesture(tester.getCenter(slider)); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(const Duration(milliseconds: 16)); + expect(volumeWrites, isNotEmpty); + expect(volumeWrites.last, greaterThan(0.5)); + await gesture.up(); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(volumeWrites, isNotEmpty); + expect(volumeWrites.last, greaterThan(0.5)); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye landscape shows lyrics automatically without bottom actions', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + tester.view.physicalSize = const Size(852, 393); + tester.view.padding = FakeViewPadding(left: 59, right: 59, bottom: 21); + addTearDown(tester.view.resetPadding); + await tester.pumpAndSettle(); + + final artwork = find.byType(Hero); + final volume = find.byType(MornyeVolumeControl); + final artRect = tester.getRect(artwork); + expect(artRect.width, closeTo(artRect.height, 0.1)); + expect(artRect.width, greaterThan(140)); + expect(artRect.top, greaterThanOrEqualTo(36)); + final header = find.text('First').hitTestable(); + final lyric = find.text('First lyric').hitTestable(); + expect(header, findsOneWidget); + expect(lyric, findsOneWidget); + expect(tester.getRect(header).left, greaterThan(artRect.right)); + expect(tester.getRect(lyric).left, greaterThan(artRect.right)); + expect( + tester.getRect(lyric).top, + greaterThan(tester.getRect(header).bottom), + ); + expect(volume, findsNothing); + expect(find.byIcon(CupertinoIcons.quote_bubble), findsNothing); + expect(find.byIcon(CupertinoIcons.list_bullet), findsNothing); + expect(tester.takeException(), isNull); + + await tester.tap(artwork); + await tester.pumpAndSettle(); + expect(volume.hitTestable(), findsOneWidget); + expect( + find.byIcon(CupertinoIcons.play_fill).hitTestable(), + findsOneWidget, + ); + final volumeSlider = find.descendant( + of: volume, + matching: find.byType(Slider), + ); + final drag = await tester.startGesture(tester.getCenter(volumeSlider)); + await drag.moveBy(const Offset(30, 0)); + await tester.pump(const Duration(seconds: 5)); + expect(volume.hitTestable(), findsOneWidget); + expect(volumeWrites, isNotEmpty); + await drag.up(); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + expect(volume, findsNothing); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.text('Second lyric').hitTestable(), findsOneWidget); + expect(find.text('First lyric'), findsNothing); + tester.view.physicalSize = const Size(393, 852); + tester.view.resetPadding(); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), + findsOneWidget, + ); + expect( + find.byIcon(CupertinoIcons.list_bullet).hitTestable(), + findsOneWidget, + ); + expect( + tester.getRect(artwork).bottom, + lessThan(tester.getRect(volume).top), + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Mornye cover stays attached throughout opening and closing', ( + tester, + ) async { + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + wrapPlayer: (_) => Consumer( + builder: (context, ref, _) { + ref.watch(currentMediaItemProvider); + return Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: TextButton( + onPressed: () => Navigator.of(context).push(NowPlayingRoute()), + child: const Hero( + tag: kNowPlayingArtworkHeroTag, + child: SizedBox.square(dimension: 38, child: Text('Open')), + ), + ), + ), + ); + }, + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open')); + await tester.pump(); + + void expectAttached() { + final panel = tester.getRect(find.byType(MornyePlayerBackground)); + final artwork = tester.getRect(find.byType(MornyePlayerArtwork)); + expect(artwork.left, closeTo(panel.left, 0.01)); + expect(artwork.top, closeTo(panel.top, 0.01)); + expect(artwork.width, closeTo(393, 0.01)); + expect(artwork.height, closeTo(393, 0.01)); + } + + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expectAttached(); + } + await tester.pumpAndSettle(); + expectAttached(); + Navigator.of(tester.element(find.byType(NowPlayingScreen))).pop(); + await tester.pump(); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expectAttached(); + expect( + tester.getTopLeft(find.byType(MornyePlayerBackground)).dy, + greaterThan(0), + ); + } + await tester.pumpAndSettle(); + expect(find.byType(NowPlayingScreen), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('Mornye lyrics replace artwork while controls stay in place', ( + tester, + ) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 700), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + final artwork = find.byType(Hero); + final fullArtwork = tester.getRect(artwork); + expect(fullArtwork.height, fullArtwork.width); + final volume = find.byType(MornyeVolumeControl); + final volumeRect = tester.getRect(volume); + final play = find.byIcon(CupertinoIcons.play_fill); + final playRect = tester.getRect(play); + final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); + await tester.tap(lyricsButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final fullCover = find.byKey(const ValueKey('full-player-artwork')); + double fullCoverOpacity() => tester + .widget( + find + .ancestor(of: fullCover, matching: find.byType(FadeTransition)) + .first, + ) + .opacity + .value; + expect(fullCover, findsOneWidget); + expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.byType(PageView), findsNothing); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + expect(tester.getSize(artwork).width, lessThan(fullArtwork.width)); + expect(fullArtwork.top, 0); + expect(fullArtwork.width, 393); + expect(tester.getRect(volume), volumeRect); + expect(tester.getRect(play), playRect); + + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.text('Second lyric').hitTestable(), findsOneWidget); + expect(find.text('First lyric'), findsNothing); + + await tester.tap(lyricsButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0)); + expect( + find.descendant( + of: find.byType(MornyePlayerBackground), + matching: find.byType(Hero), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect(tester.getRect(artwork), fullArtwork); + expect(find.text('Second lyric').hitTestable(), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( 'automatic SAF track change refreshes lyrics while Lyrics page is active', (tester) async { @@ -92,6 +955,68 @@ void main() { }, ); + testWidgets('timed lyric fills text fragments in reading order', ( + tester, + ) async { + const lyricText = 'AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH'; + metadataOverrides['lyrics'] = + '
' + '

' + '$lyricText' + '

'; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(320, 844), + playback: PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: const Duration(seconds: 2), + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + + final paintFinder = find.descendant( + of: find.bySemanticsLabel(lyricText), + matching: find.byType(CustomPaint), + ); + final painter = tester.widget(paintFinder).painter!; + final size = tester.getSize(paintFinder); + final highlightedPixels = await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + size.width.ceil(), + size.height.ceil(), + ); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + var upper = 0; + var lower = 0; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + if (bytes.getUint8((y * image.width + x) * 4 + 3) < 230) continue; + if (y < image.height ~/ 2) { + upper++; + } else { + lower++; + } + } + } + image.dispose(); + picture.dispose(); + return (upper, lower); + }); + expect(highlightedPixels!.$1, greaterThan(0)); + expect(highlightedPixels.$2, 0); + expect(tester.takeException(), isNull); + }); + testWidgets('short timed lyric remains horizontally centered', ( tester, ) async { @@ -140,3 +1065,27 @@ void main() { expect(find.text('Turn off sleep timer'), findsNothing); }); } + +class _TestCollections extends LibraryCollectionsNotifier { + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); + + @override + Future toggleLoved(Track track) async { + final key = trackCollectionKey(track); + final added = !state.isLoved(track); + state = state.copyWith( + loved: added + ? [ + ...state.loved, + CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ), + ] + : state.loved.where((entry) => entry.key != key).toList(), + ); + return added; + } +} diff --git a/test/now_playing_route_test.dart b/test/now_playing_route_test.dart new file mode 100644 index 00000000..2575eb58 --- /dev/null +++ b/test/now_playing_route_test.dart @@ -0,0 +1,166 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +const _sheet = Key('player-sheet'); + +class _TestPlayerRoute extends NowPlayingRoute { + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => const ColoredBox(key: _sheet, color: Colors.black); +} + +void main() { + for (final brightness in Brightness.values) { + testWidgets( + 'player fills safe areas and reveals the page only when dragged in $brightness', + (tester) async { + tester.view.physicalSize = const Size(393, 852); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPadding); + final navigator = GlobalKey(); + final capture = GlobalKey(); + const background = Color(0xffff00ff); + final theme = MornyeTheme.build(brightness); + final playerBackground = theme.colorScheme.surface; + await tester.pumpWidget( + RepaintBoundary( + key: capture, + child: ProviderScope( + overrides: [ + currentMediaItemProvider.overrideWith( + (ref) => Stream.value(null), + ), + ], + child: MaterialApp( + navigatorKey: navigator, + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ColoredBox( + color: background, + child: const SizedBox.expand(), + ), + ), + ), + ), + ); + final route = NowPlayingRoute(); + navigator.currentState!.push(route); + await tester.pumpAndSettle(); + + Future expectColorAt(int x, int y, Color expected) async { + final pixel = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final offset = (y * image.width + x) * 4; + final color = Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ); + image.dispose(); + return color; + }); + expect(pixel, expected); + } + + await expectColorAt(196, 30, playerBackground); + await expectColorAt(1, 1, playerBackground); + await expectColorAt(196, 840, playerBackground); + expect(tester.getTopLeft(find.byType(AppBar).last).dy, 0); + expect( + tester.getTopLeft(find.byIcon(Icons.keyboard_arrow_down)).dy, + greaterThanOrEqualTo(59), + ); + route.startDrag(); + route.updateDrag( + DragUpdateDetails( + globalPosition: const Offset(0, 213), + delta: const Offset(0, 213), + primaryDelta: 213, + ), + 852, + ); + await tester.pump(); + await expectColorAt(196, 100, background); + await expectColorAt(196, 243, playerBackground); + route.cancelDrag(); + await tester.pumpAndSettle(); + await expectColorAt(196, 30, playerBackground); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final dismiss in [false, true]) { + testWidgets('Player drag settles without a position jump (pop: $dismiss)', ( + tester, + ) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: const SizedBox()), + ); + final route = _TestPlayerRoute(); + navigator.currentState!.push(route); + await tester.pumpAndSettle(); + final height = tester.getSize(find.byKey(_sheet)).height; + + route.startDrag(); + route.updateDrag( + DragUpdateDetails( + globalPosition: Offset(0, height * 0.4), + primaryDelta: height * 0.4, + delta: Offset(0, height * 0.4), + ), + height, + ); + await tester.pump(); + final releasedTop = tester.getTopLeft(find.byKey(_sheet)).dy; + if (dismiss) { + route.endDrag(DragEndDetails(primaryVelocity: 0), height); + } else { + route.cancelDrag(); + } + await tester.pump(); + expect( + tester.getTopLeft(find.byKey(_sheet)).dy, + closeTo(releasedTop, 0.1), + ); + + var previousTop = releasedTop; + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final top = tester.getTopLeft(find.byKey(_sheet)).dy; + expect(top, dismiss ? greaterThan(previousTop) : lessThan(previousTop)); + previousTop = top; + } + await tester.pumpAndSettle(); + if (dismiss) { + expect(find.byKey(_sheet), findsNothing); + } else { + expect(tester.getTopLeft(find.byKey(_sheet)).dy, 0); + } + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/playback_artwork_test.dart b/test/playback_artwork_test.dart new file mode 100644 index 00000000..0a7e11aa --- /dev/null +++ b/test/playback_artwork_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/playback_artwork.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory.systemTemp.createTemp('restored-artwork-'); + }); + tearDown(() => directory.delete(recursive: true)); + + test( + 'restored queue replaces a removed scan cover with current Library art', + () async { + final removed = File( + '${directory.path}/old-scan/cover.jpg', + ).uri.toString(); + final current = File('${directory.path}/current cover #1.jpg'); + await current.writeAsBytes([1]); + final resolved = await resolveRestoredArtworkUri( + removed, + loadLibraryCoverPath: () async => current.path, + ); + expect(resolved, current.uri.toString()); + expect(File(Uri.parse(resolved!).toFilePath()).existsSync(), isTrue); + }, + ); + + test( + 'valid local and remote covers do not trigger Library lookups', + () async { + final current = File('${directory.path}/cover.jpg'); + await current.writeAsBytes([1]); + for (final artwork in [ + current.path, + current.uri.toString(), + 'https://example.com/cover.jpg', + ]) { + var lookups = 0; + final resolved = await resolveRestoredArtworkUri( + artwork, + loadLibraryCoverPath: () async { + lookups++; + return null; + }, + ); + expect(resolved, artwork); + expect(lookups, 0); + } + }, + ); + + test( + 'a missing Library image or lookup failure leaves the session usable', + () async { + final missing = File('${directory.path}/missing.jpg').uri.toString(); + expect( + await resolveRestoredArtworkUri( + missing, + loadLibraryCoverPath: () async => + '${directory.path}/also-missing.jpg', + ), + missing, + ); + expect( + await resolveRestoredArtworkUri( + missing, + loadLibraryCoverPath: () async => + throw StateError('Library unavailable'), + ), + missing, + ); + }, + ); +} diff --git a/test/player_motion_artwork_test.dart b/test/player_motion_artwork_test.dart new file mode 100644 index 00000000..0de4886d --- /dev/null +++ b/test/player_motion_artwork_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; + +void main() { + const album = (album: 'An Album', artist: 'An Artist'); + + test( + 'motion lookup checks album and artist before loading metadata', + () async { + final loaded = []; + final result = await findPlayerMotionArtwork( + album: album, + providerIds: ['provider-a'], + search: (_, _) async => [ + { + 'id': 'wrong-artist', + 'name': 'An Album', + 'artists': 'Not An Artist', + }, + { + 'id': 'wrong-album', + 'name': 'Another Album', + 'artists': 'An Artist', + }, + {'id': 'match', 'name': 'AN ALBUM', 'artists': 'An Artist'}, + ], + loadAlbum: (_, id) async { + loaded.add(id); + return { + 'album_info': {'header_video': 'https://example.com/cover.m3u8'}, + }; + }, + ); + expect(loaded, ['match']); + expect(result, 'https://example.com/cover.m3u8'); + }, + ); + + test('motion lookup falls back to another provider after an error', () async { + final result = await findPlayerMotionArtwork( + album: album, + providerIds: ['provider-a', 'provider-b'], + search: (provider, _) async { + if (provider == 'provider-a') throw StateError('offline'); + return [ + { + 'name': 'An Album', + 'artists': 'An Artist', + 'header_video': 'https://example.com/motion.m3u8', + }, + ]; + }, + loadAlbum: (_, _) async => + throw StateError('Direct artwork needs no lookup'), + ); + expect(result, 'https://example.com/motion.m3u8'); + }); + + test('unavailable or unsupported motion keeps the static cover', () async { + final result = await findPlayerMotionArtwork( + album: album, + providerIds: ['provider-a'], + search: (_, _) async => [ + { + 'name': 'An Album', + 'artists': 'An Artist', + 'header_video': 'file:///invalid.m3u8', + }, + ], + loadAlbum: (_, _) async => {}, + ); + expect(result, isNull); + }); +} diff --git a/test/shell_navigation_service_test.dart b/test/shell_navigation_service_test.dart index 382c35a8..01519fb7 100644 --- a/test/shell_navigation_service_test.dart +++ b/test/shell_navigation_service_test.dart @@ -5,6 +5,71 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; void main() { + testWidgets('first search request reaches a lazily mounted Home tab', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: _SearchShell())); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }); + + testWidgets('one search request returns from an album and focuses Home', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: _SearchShell())); + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + final navigator = ShellNavigationService.homeTabNavigatorKey.currentState!; + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Album')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + expect(find.text('Album'), findsNothing); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }); + + testWidgets( + 'search selects Home before requesting focus and stops when shell is absent', + (tester) async { + final owner = Object(); + var selectedHome = false; + var focusRequests = 0; + void onSearch() { + expect(selectedHome, isTrue); + focusRequests++; + } + + ShellNavigationService.homeSearchRequests.addListener(onSearch); + addTearDown(() { + ShellNavigationService.homeSearchRequests.removeListener(onSearch); + ShellNavigationService.unregisterTabSelectionHandler(owner); + }); + ShellNavigationService.registerTabSelectionHandler( + owner: owner, + handler: (tab) => selectedHome = tab == ShellTab.home, + ); + ShellNavigationService.requestHomeSearch(); + expect(focusRequests, 0); + await tester.pump(); + expect(focusRequests, 1); + ShellNavigationService.unregisterTabSelectionHandler(owner); + ShellNavigationService.requestHomeSearch(); + expect(focusRequests, 1); + }, + ); + group('ShellNavigationService tab requests', () { test('forwards a named tab request to the registered shell', () { final owner = Object(); @@ -88,3 +153,102 @@ void main() { }); }); } + +class _SearchShell extends StatefulWidget { + const _SearchShell(); + + @override + State<_SearchShell> createState() => _SearchShellState(); +} + +class _SearchShellState extends State<_SearchShell> { + final _pages = PageController(initialPage: 1); + late final _observer = ShellChromeObserver( + ShellNavigationService.homeTabNavigatorKey, + ); + + @override + void initState() { + super.initState(); + ShellNavigationService.syncState(currentTabIndex: 1, showRepoTab: false); + ShellNavigationService.registerTabSelectionHandler( + owner: this, + handler: (_) { + ShellNavigationService.syncState( + currentTabIndex: 0, + showRepoTab: false, + ); + _pages.jumpToPage(0); + }, + ); + } + + @override + void dispose() { + ShellNavigationService.unregisterTabSelectionHandler(this); + _observer.detach(); + _pages.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: Column( + children: [ + TextButton( + onPressed: ShellNavigationService.requestHomeSearch, + child: const Text('Search'), + ), + Expanded( + child: PageView.builder( + controller: _pages, + itemCount: 2, + itemBuilder: (_, index) => index == 1 + ? const Text('Library') + : Navigator( + key: ShellNavigationService.homeTabNavigatorKey, + observers: [_observer], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const _SearchHome(), + ), + ), + ), + ), + ], + ), + ); +} + +class _SearchHome extends StatefulWidget { + const _SearchHome(); + + @override + State<_SearchHome> createState() => _SearchHomeState(); +} + +class _SearchHomeState extends State<_SearchHome> { + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + ShellNavigationService.homeSearchRequests.addListener(_onSearch); + } + + void _onSearch() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focus.requestFocus(); + }); + } + + @override + void dispose() { + ShellNavigationService.homeSearchRequests.removeListener(_onSearch); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + Scaffold(body: TextField(focusNode: _focus)); +} diff --git a/test/track_metadata_header_contrast_test.dart b/test/track_metadata_header_contrast_test.dart index b6c69238..0eb94d8d 100644 --- a/test/track_metadata_header_contrast_test.dart +++ b/test/track_metadata_header_contrast_test.dart @@ -1,13 +1,153 @@ +import 'dart:io'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; void main() { + for (final useFileUri in [false, true]) { + testWidgets('Mornye metadata renders local artwork (URI=$useFileUri)', ( + tester, + ) async { + final cover = await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp( + 'metadata-cover-', + ); + addTearDown(() => directory.delete(recursive: true)); + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.green, BlendMode.src); + final picture = recorder.endRecording(); + final image = await picture.toImage(32, 32); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + return File('${directory.path}/cover with spaces #1.png') + ..writeAsBytesSync(bytes!.buffer.asUint8List()); + }); + final item = LocalLibraryItem( + id: 'local-cover-track', + trackName: 'Track with artwork', + artistName: 'Artist', + albumName: 'Album', + filePath: '/missing/local-cover-track.flac', + coverPath: useFileUri ? cover!.uri.toString() : cover!.path, + scannedAt: DateTime(2026), + ); + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TrackMetadataScreen(localItem: item), + ), + ), + ); + await precacheImage( + FileImage(cover), + tester.element(find.byType(TrackMetadataScreen)), + ); + }); + await tester.pumpAndSettle(); + + final artwork = find.descendant( + of: find.byType(Hero), + matching: find.byType(RawImage), + ); + expect(artwork, findsOneWidget); + expect(tester.widget(artwork).image, isNotNull); + expect(find.byIcon(Icons.music_note), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await FileImage(cover).evict(); + }); + } + + for (final brightness in Brightness.values) { + testWidgets( + 'Mornye metadata has single-level sections and stacked values ($brightness)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 780)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final item = DownloadHistoryItem( + id: 'metadata-track', + trackName: 'A long track title for a narrow screen', + artistName: 'Artist', + albumName: 'Album', + filePath: '/missing/track.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + isrc: 'USAAA2600001', + ); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(1.6)), + child: child!, + ), + home: TrackMetadataScreen(item: item), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.chevron_back), findsOneWidget); + expect(find.byIcon(CupertinoIcons.ellipsis), findsOneWidget); + expect(find.byType(Card), findsNothing); + final row = find.widgetWithText(MornyeMetadataRow, 'Track name'); + expect( + find.ancestor(of: row, matching: find.byType(MornyeGlass)), + findsNothing, + ); + expect( + find.ancestor( + of: row, + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == MornyeTheme.controlFill(tester.element(row)), + ), + ), + findsOneWidget, + ); + final value = find.descendant( + of: row, + matching: find.text(item.trackName), + ); + expect( + tester.getTopLeft(value).dy, + greaterThan(tester.getBottomLeft(find.text('Track name')).dy), + ); + expect( + tester.widget(value).style?.color, + MornyeTheme.build(brightness).colorScheme.onSurface, + ); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget); + expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('metadata hero keeps technical text legible in light theme', ( tester, ) async {