feat(ui): add Mornye glass theme and adaptive music player

Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
This commit is contained in:
zarzet committed 2026-09-19 12:10:10 +07:00
1 parent 1ebdac8645
commit 63e1a9b9c8
155 files changed
+15707 -2318

No files matched your search

+20
View File
@@ -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"
+15 -1
View File
@@ -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"
}
+6
View File
@@ -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;
+19 -2
View File
@@ -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<String, dynamic> 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) {
+26 -5
View File
@@ -194,12 +194,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
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<ExtensionState> {
}
}
Future<void> loadExtensions(String dirPath) async {
Future<bool> 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<dynamic>? ?? const [])
.whereType<String>()
.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;
}
}
+45
View File
@@ -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<MediaItem?>((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<Track, MediaItem>((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<PlaybackState>((ref) {
return musicPlayerPlaybackStateEvents();
});
@@ -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<Future<String?>>(
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<String?, PlayerArtworkAlbum>((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 = <String>{
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<String?> findPlayerMotionArtwork({
required PlayerArtworkAlbum album,
required List<String> providerIds,
required Future<List<Map<String, dynamic>>> Function(
String provider,
String query,
)
search,
required Future<Map<String, dynamic>> 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;
}
+8
View File
@@ -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<ThemeSettings> {
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<ThemeSettings> {
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<ThemeSettings> {
await _saveToStorage();
}
Future<void> setStyle(AppThemeStyle style) async {
state = state.copyWith(style: style);
await _saveToStorage();
}
Future<void> setUseDynamicColor(bool value) async {
state = state.copyWith(useDynamicColor: value);
await _saveToStorage();
+1 -1
View File
@@ -562,7 +562,7 @@ class TrackNotifier extends Notifier<TrackState> {
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(),
),
+67 -11
View File
@@ -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<List<Track>>(
@@ -274,6 +276,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
final items = <Widget>[];
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<AlbumScreen>
@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<AlbumScreen>
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
backgroundColor: pageBackgroundColor,
bottomInset: bottomInset,
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
@@ -366,10 +384,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
Widget _buildAddToPlaylistButton(BuildContext context) {
return HeaderCircleButton(
icon: Icons.add,
tonal: true,
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks == null || _tracks!.isEmpty
? null
+130 -84
View File
@@ -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<ArtistScreen>
@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<ArtistScreen>
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<ArtistScreen>
CustomScrollView(
controller: _scrollController,
slivers: [
_buildHeader(
..._buildHeader(
context,
colorScheme,
albums: albums,
@@ -523,10 +539,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
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<ArtistScreen>
),
),
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<ArtistScreen>
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistAlbums,
albumsOnly,
colorScheme,
@@ -568,6 +591,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
if (singles.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistSingles,
singles,
colorScheme,
@@ -577,6 +601,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
if (compilations.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistCompilations,
compilations,
colorScheme,
@@ -586,7 +611,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
const NavBarSliverSpacer(),
],
),
],
@@ -695,87 +720,108 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
(sum, a) => sum + a.totalTracks,
);
final singleTracks = singles.fold<int>(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<void>(
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<ArtistScreen>
}
final progressDialogKey = GlobalKey<_FetchingProgressDialogState>();
showDialog<void>(
showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
+246 -31
View File
@@ -2,7 +2,7 @@
part of 'artist_screen.dart';
extension _ArtistScreenSections on _ArtistScreenState {
Widget _buildHeader(
List<Widget> _buildHeader(
BuildContext context,
ColorScheme colorScheme, {
required List<ArtistAlbum> 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<ArtistAlbum> 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),
),
+27 -12
View File
@@ -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<DownloadedAlbumScreen>
@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<DownloadedAlbumScreen>
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<DownloadHistoryItem> 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<DownloadedAlbumScreen>
tracks,
bottomPadding,
),
bottomInset: bottomInset,
);
}
@@ -363,6 +375,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
return AlbumDetailHeader(
title: widget.albumName,
immersive: context.isMornye,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: background,
@@ -379,9 +392,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
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,
+33 -4
View File
@@ -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<HomeTab>
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<HomeTab>
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<HomeTab>
@override
void dispose() {
ShellNavigationService.homeSearchRequests.removeListener(
_focusSearchFromShell,
);
_homeScrollController.dispose();
_liveSearchDebounce?.cancel();
_trackStateSub.close();
_extensionInitSub.close();
@@ -336,6 +352,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
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<HomeTab>
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<HomeTab>
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<HomeTab>
16,
(hasResults || showExplore) ? 8 : 16,
),
child: _buildSearchBar(colorScheme),
child: _buildSearchBar(),
),
),
@@ -1044,7 +1068,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
},
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
const NavBarSliverSpacer(),
],
),
),
@@ -1096,6 +1120,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
// 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(
+27 -16
View File
@@ -17,7 +17,7 @@ extension _HomeTabCsvImport on _HomeTabState {
if (progressDialogInitialized || !mounted) return;
progressDialogInitialized = true;
progressDialogVisible = true;
showDialog<void>(
showAppDialog<void>(
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(
+6 -4
View File
@@ -25,18 +25,20 @@ extension _HomeTabRecentUI on _HomeTabState {
TextButton(
onPressed: () async {
// Clearing history is not undoable, so ask first.
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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),
+112 -90
View File
@@ -177,14 +177,18 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
void _showSortOptions(ColorScheme colorScheme) {
var tempSort = _searchSortOption;
final mornye = context.isMornye;
showModalBottomSheet<void>(
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();
},
);
}
+123 -8
View File
@@ -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<ExtensionAlbumScreen> {
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<ExtensionArtistScreen>
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(),
+48 -35
View File
@@ -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<void>(
showAppModalBottomSheet<void>(
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<FormState>();
final nextName = await showDialog<String>(
final nextName = await showAppDialog<String>(
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());
+22 -7
View File
@@ -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<LocalAlbumScreen>
@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<LocalAlbumScreen>
tracks,
bottomPadding,
),
bottomInset: bottomInset,
);
}
@@ -225,6 +237,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
return AlbumDetailHeader(
title: widget.albumName,
immersive: context.isMornye,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: background,
@@ -258,9 +271,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
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,
+98 -14
View File
@@ -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<MainShell>
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<MainShell>
_safRepairDialogVisible = true;
try {
await showDialog<void>(
await showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
@@ -172,7 +177,7 @@ class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
title: Text(context.l10n.downloadFolderAccessLostTitle),
content: Text(context.l10n.downloadFolderAccessLostSubtitle),
actions: [
TextButton(
AppDialogAction(
onPressed: isPickingFolder
? null
: () {
@@ -194,7 +199,8 @@ class _MainShellState extends ConsumerState<MainShell>
},
child: Text(context.l10n.storageAutomaticFolder),
),
FilledButton(
AppDialogAction(
filled: true,
onPressed: isPickingFolder
? null
: () async {
@@ -393,10 +399,10 @@ class _MainShellState extends ConsumerState<MainShell>
final colorScheme = Theme.of(context).colorScheme;
showDialog<void>(
showAppDialog<void>(
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<MainShell>
),
),
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<MainShell>
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpTransitionController.dispose();
_mornyeChrome.dispose();
super.dispose();
}
@@ -489,6 +497,7 @@ class _MainShellState extends ConsumerState<MainShell>
}
void _onNavTap(int index) {
_mornyeChrome.expand();
if (index == 0 && _currentIndex == 0) {
_resetHomeToMain();
return;
@@ -513,7 +522,12 @@ class _MainShellState extends ConsumerState<MainShell>
// 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<MainShell>
}
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<MainShell>
final l10n = context.l10n;
final destinations = <NavigationDestination>[
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<MainShell>
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<MainShell>
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<MainShell>
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<MainShell>
// 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<MainShell>
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: tabs[index],
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (canMinimizeChrome && index == _currentIndex) {
return _mornyeChrome.handleScroll(notification);
}
return false;
},
child: tabs[index],
),
),
),
),
@@ -858,6 +898,45 @@ class _MainShellState extends ConsumerState<MainShell>
: 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<bool>(
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,
],
File diff suppressed because it is too large. Load diff
+51 -13
View File
@@ -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<PlaylistScreen>
@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<PlaylistScreen>
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
bottomInset: context.navBarBottomInset,
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
@@ -310,6 +324,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
return AlbumDetailHeader(
title: _playlistName,
immersive: context.isMornye,
squareArtwork: !hasMotion,
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: hasMotion
@@ -343,11 +359,24 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
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<PlaylistScreen>
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<PlaylistScreen>
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<PlaylistScreen>
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<PlaylistScreen>
Widget _buildAddToPlaylistButton(BuildContext context) {
return HeaderCircleButton(
icon: Icons.playlist_add,
tonal: true,
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty
? null
+67 -93
View File
@@ -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<QueueTab> {
String? tempMetadata = _filterMetadata;
String tempSortMode = _sortMode;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
@@ -831,13 +838,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
() => tempSource = 'downloaded',
),
),
FilterChip(
AppChoiceChip(
label: Text(context.l10n.libraryFilterLocal),
selected: tempSource == 'local',
onSelected: (_) =>
@@ -865,13 +872,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
onSelected: (_) =>
setSheetState(() => tempQuality = 'hires'),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterQualityCD,
),
@@ -887,7 +894,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onSelected: (_) =>
setSheetState(() => tempQuality = 'cd'),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterQualityLossy,
),
@@ -908,7 +915,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Wrap(
spacing: 8,
children: [
FilterChip(
AppChoiceChip(
label: Text(context.l10n.libraryFilterAll),
selected: tempFormat == null,
onSelected: (_) =>
@@ -916,7 +923,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
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<QueueTab> {
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<QueueTab> {
() => tempMetadata = 'complete',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterMetadataMissingAny,
),
@@ -960,7 +967,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-any',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterMetadataMissingYear,
),
@@ -969,7 +976,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-year',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -980,7 +987,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-genre',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -992,7 +999,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-album-artist',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -1004,7 +1011,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-track-number',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -1015,7 +1022,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-disc-number',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -1026,7 +1033,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-artist',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -1038,7 +1045,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'incorrect-isrc-format',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterMetadataMissingIsrc,
),
@@ -1047,7 +1054,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-isrc',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -1058,7 +1065,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-label',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context
.l10n
@@ -1082,7 +1089,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Wrap(
spacing: 8,
children: [
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterSortLatest,
),
@@ -1091,7 +1098,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempSortMode = 'latest',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterSortOldest,
),
@@ -1100,33 +1107,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => 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<QueueTab> {
() => tempSortMode = 'album-asc',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterSortAlbumDesc,
),
@@ -1144,21 +1151,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => 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<QueueTab> {
() => tempSortMode = 'genre-asc',
),
),
FilterChip(
AppChoiceChip(
label: Text(
context.l10n.libraryFilterSortGenreDesc,
),
@@ -1182,7 +1189,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
SizedBox(
width: double.infinity,
child: FilledButton(
child: AppDialogAction(
filled: true,
onPressed: () {
setState(() {
_filterSource = tempSource;
@@ -1443,12 +1451,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
: false,
isPageLoading: isLibraryPageLoading,
inMemoryHistoryItems: inMemoryHistoryItems,
bottomInset: bottomInset,
);
},
),
@@ -1753,17 +1723,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
BuildContext context,
DownloadItem item,
) async {
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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<QueueTab> {
: (item.errorMessage.trim().isNotEmpty
? _localizedDownloadError(context, item.errorMessage)
: context.l10n.updateDownloadFailed);
final action = await showDialog<String>(
final action = await showAppDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => AppAlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Column(
@@ -1823,22 +1794,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
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),
),
+7 -6
View File
@@ -42,7 +42,6 @@ extension _QueueTabFilterWidgets on _QueueTabState {
required bool hasMoreLibrary,
required bool isPageLoading,
required List<DownloadHistoryItem> 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<bool>(
final confirmed = await showAppDialog<bool>(
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),
+7 -5
View File
@@ -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<bool>(
return await showAppDialog<bool>(
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),
),
+19 -12
View File
@@ -181,9 +181,9 @@ extension _QueueTabSelectionActions on _QueueTabState {
return;
}
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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<void> _deleteSelectedPlaylists(BuildContext context) async {
final count = _selectedPlaylistIds.length;
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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<void> _deleteSelected(List<UnifiedLibraryItem> allItems) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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,
+77 -24
View File
@@ -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,
+95 -58
View File
@@ -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<void> _uninstallExtension(RepoExtension ext) async {
final confirm = await showDialog<bool>(
final confirm = await showAppDialog<bool>(
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,
),
+208 -82
View File
@@ -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<RepoTab> {
);
}
final colorScheme = Theme.of(context).colorScheme;
final bottomInset = context.navBarBottomInset;
return Scaffold(
body: RefreshIndicator(
@@ -93,7 +100,9 @@ class _RepoTabState extends ConsumerState<RepoTab> {
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<RepoTab> {
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<RepoTab> {
),
),
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<RepoTab> {
void _showChangeRepoDialog(String currentUrl) {
final changeUrlController = TextEditingController(text: currentUrl);
showDialog<void>(
showAppDialog<void>(
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<RepoTab> {
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<RepoTab> {
),
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<RepoTab> {
.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<RepoTab> {
.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),
),
+39 -24
View File
@@ -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<void>(
showAppDialog<void>(
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<void>(
showAppDialog<void>(
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<void>(
showAppDialog<void>(
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),
@@ -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<void>(
showAppModalBottomSheet<void>(
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
@@ -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<BackupRestorePage> {
Future<bool?> _confirmRestore(BackupBundle bundle) {
final l10n = context.l10n;
return showDialog<bool>(
return showAppDialog<bool>(
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<BackupRestorePage> {
],
),
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),
),
+57 -22
View File
@@ -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<CacheManagementPage> {
}
Future<bool> _confirmClear(String target) async {
final confirm = await showDialog<bool>(
final confirm = await showAppDialog<bool>(
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<CacheManagementPage> {
}
Future<bool> _confirmClearAll() async {
final confirm = await showDialog<bool>(
final confirm = await showAppDialog<bool>(
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<CacheManagementPage> {
);
}
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<CacheManagementPage> {
)
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<CacheManagementPage> {
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<CacheManagementPage> {
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<CacheManagementPage> {
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<CacheManagementPage> {
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),
@@ -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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -506,7 +507,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
),
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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
int current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -575,7 +576,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
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<DownloadSettingsPage> {
];
final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
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
+300 -166
View File
@@ -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<ExtensionDetailPage> {
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<ExtensionDetailPage> {
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<ExtensionDetailPage> {
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<ExtensionDetailPage> {
),
)
: 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<ExtensionDetailPage> {
],
),
),
Switch(
AppSwitch(
value: extension.enabled,
semanticLabel: extension.displayName,
onChanged: hasError
? null
: (enabled) => ref
@@ -191,16 +206,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
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<ExtensionDetailPage> {
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<ExtensionDetailPage> {
),
SliverToBoxAdapter(
child: SettingsGroup(
glass: true,
children: [
_HealthSummaryItem(
status: healthStatus,
@@ -286,6 +308,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
SliverToBoxAdapter(
child: SettingsGroup(
glass: true,
children: [
_URLHandlerInfo(patterns: extension.urlHandler!.patterns),
],
@@ -302,6 +325,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
SliverToBoxAdapter(
child: SettingsGroup(
glass: true,
children: extension.qualityOptions.asMap().entries.map((
entry,
) {
@@ -325,6 +349,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
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<ExtensionDetailPage> {
),
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<ExtensionDetailPage> {
),
SliverToBoxAdapter(
child: SettingsGroup(
glass: true,
children: [
_CapabilityItem(
icon: Icons.search,
@@ -437,10 +464,10 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
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<ExtensionDetailPage> {
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<ExtensionDetailPage> {
Future<void> _confirmRemove(BuildContext context) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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<ExtensionDetailPage> {
}
}
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<String>(
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<String>(
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<void> _showOptions() async {
final value = await showAppBottomSheet<String>(
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 ?? <String>[])
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<void> _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<void>(
showAppDialog<void>(
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),
],
);
}
+130 -17
View File
@@ -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<ExtensionsPage> {
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<ExtensionsPage> {
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<ExtensionsPage> {
});
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<ExtensionsPage> {
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<void>(
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<void>(
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<void>(
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: [
+32 -18
View File
@@ -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<FilesSettingsPage> {
if (mounted) setState(() => _hasAllFilesAccess = true);
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
final shouldOpen = await showAppDialog<bool>(
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<FilesSettingsPage> {
final settings = ref.read(settingsProvider);
final isSafMode =
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -513,7 +519,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
),
),
),
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<FilesSettingsPage> {
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<FilesSettingsPage> {
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -574,7 +580,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
),
),
),
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<FilesSettingsPage> {
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<FilesSettingsPage> {
final save =
onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
@@ -685,7 +691,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -755,7 +761,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
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<FilesSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
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);
+32 -20
View File
@@ -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<LibrarySettingsPage> {
return true;
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
final shouldOpen = await showAppDialog<bool>(
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<LibrarySettingsPage> {
}
Future<void> _clearLibrary() async {
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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<LibrarySettingsPage> {
}
Future<void> _removeSource(LocalLibrarySource source) async {
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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<LibrarySettingsPage> {
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -397,7 +407,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
('singles', Icons.music_note, context.l10n.historyFilterSingles),
('playlists', Icons.queue_music, context.l10n.searchPlaylists),
];
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -484,7 +494,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
context.l10n.libraryQualityLabelFileFormat,
),
];
showModalBottomSheet<void>(
showAppModalBottomSheet<void>(
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,
+16 -41
View File
@@ -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<LogScreen> {
}
void _clearLogs() {
showDialog<void>(
showAppDialog<void>(
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<LogScreen> {
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),
),
),
],
@@ -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(),
),
],
),
+57 -37
View File
@@ -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<void>(
showAppModalBottomSheet<void>(
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<void>(
showAppModalBottomSheet<void>(
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],
],
);
},
),
],
),
@@ -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<void>(
showAppModalBottomSheet<void>(
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<void>(
showAppModalBottomSheet<void>(
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(
+32 -28
View File
@@ -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<SettingsTab> {
final destination = targetLabel == null
? page
: SettingsSearchHighlightScope(targetLabel: targetLabel, child: page);
await Navigator.of(context).push(slidePageRoute<void>(page: destination));
await Navigator.of(
context,
).push(slidePageRoute<void>(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<SettingsTab> {
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<SettingsTab> {
@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<Widget> body;
@@ -384,31 +386,33 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
];
}
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()),
],
),
);
}
}
+16 -12
View File
@@ -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<SetupScreen> {
}
Future<bool?> _showAndroid11StorageDialog() {
return showDialog<bool>(
return showAppDialog<bool>(
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<SetupScreen> {
}
Future<void> _showPermissionDeniedDialog(String permissionType) async {
await showDialog<void>(
await showAppDialog<void>(
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<SetupScreen> {
if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
final useDefault = await showAppDialog<bool>(
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),
),
+10 -10
View File
@@ -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<void>(
showAppDialog<void>(
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),
),
],
),
+58 -21
View File
@@ -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,
),
+12 -8
View File
@@ -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<CueSplitTrackInfo> tracks,
}) {
showDialog<void>(
showAppDialog<void>(
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<void>(
showAppDialog<void>(
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(
+256 -94
View File
@@ -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<Widget> 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,
),
);
}
}
+17 -12
View File
@@ -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,
),
+174
View File
@@ -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,
);
}
}
+62 -1
View File
@@ -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<TrackMetadataScreen>
@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<TrackMetadataScreen>
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),
+248 -183
View File
@@ -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 = <List<MornyeMenuAction>>[];
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 = <Widget>[
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<void>(
context: screenContext,
anchor: anchor,
builder: buildMenu,
);
return;
}
showModalBottomSheet<void>(
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,
);
}
+5 -17
View File
@@ -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(
+16 -8
View File
@@ -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<void> 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<void> _performBatchConversion(
final isLossless = isLosslessConversionTarget(targetFormat);
final losslessLabels = context.l10n.losslessConversionLabels;
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
keepOriginal
@@ -197,11 +201,13 @@ Future<void> _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<void> runBatchReplayGain(
onConfirmOpen?.call();
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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),
),
+8 -3
View File
@@ -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 = <String, Object?>{};
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);
+6 -4
View File
@@ -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<void> queueLocalTracksAsFlac(
return;
}
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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),
),
+55 -5
View File
@@ -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<String, dynamic> json) {
static PlayableMedia? fromJson(
Map<String, dynamic> 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<void> restorePersistedPlaybackSession() async {
final items = <PlayableMedia>[];
final keptOriginalIndices = <int>[];
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<String, dynamic>.from(entry));
final media = PlayableMedia.fromJson(
Map<String, dynamic>.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<void> 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,
+139 -4
View File
@@ -3,6 +3,76 @@ import 'package:flutter/widgets.dart';
enum ShellTab { home, library, repository, settings }
class ShellNavigationService {
static final homeSearchRequests = ValueNotifier<int>(0);
static final chromeBrightness = ValueNotifier<Brightness?>(null);
static final chromeSurface = ValueNotifier<Color?>(null);
static final _visiblePages = <GlobalKey<NavigatorState>, Route<dynamic>?>{};
static final _chromeScopes =
<
Object,
({ModalRoute<dynamic> route, Brightness brightness, Color? surface})
>{};
static bool _chromeUpdateScheduled = false;
static int _homeSearchGeneration = 0;
static void setChromeBrightness({
required Object owner,
required ModalRoute<dynamic> 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<NavigatorState> homeTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> 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<NavigatorState>? _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<NavigatorState> navigatorKey;
final _routes = <Route<dynamic>>[];
void _update() {
Route<dynamic>? page;
for (final route in _routes.reversed) {
if (route is PageRoute<dynamic>) {
page = route;
break;
}
}
ShellNavigationService._visiblePages[navigatorKey] = page;
ShellNavigationService._scheduleChromeUpdate();
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.add(route);
_update();
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.remove(route);
_update();
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.remove(route);
_update();
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? 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();
}
}
+10
View File
@@ -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;
+161
View File
@@ -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,
};
}
+320
View File
@@ -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<MornyeTheme> {
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<MornyeTheme>()?.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: <ThemeExtension<dynamic>>[
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<MornyeTheme>() != 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),
);
}
}
+8 -4
View File
@@ -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<int?> confirmAndDeleteTracks({
Future<void> Function(List<String> ids)? persistDeletedItems,
required VoidCallback onExitSelectionMode,
}) async {
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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,
+14
View File
@@ -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),
);
}
+26
View File
@@ -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<String?> resolveRestoredArtworkUri(
String? artwork, {
required Future<String?> 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;
}
+333 -22
View File
@@ -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<Widget> 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,
);
+9 -2
View File
@@ -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),
),
),
);
+343 -47
View File
@@ -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),
],
),
);
}),
),
),
],
);
}
+98
View File
@@ -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);
}
}
+291
View File
@@ -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<T?> showAppDialog<T>({
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<T>(
_MornyeDialogRoute<T>(
context: context,
builder: (context) => themes.wrap(Builder(builder: builder)),
barrierDismissible: barrierDismissible,
),
);
}
return showDialog<T>(
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<T> extends RawDialogRoute<T> {
_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<double> animation,
Animation<double> 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<double>(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<Widget> 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);
}
}
+6 -4
View File
@@ -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 = <Widget>[
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<void> showAppAnnouncementDialog(
required RemoteAnnouncement announcement,
required VoidCallback onDismiss,
}) {
return showDialog<void>(
return showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) =>
+132 -2
View File
@@ -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<T?> showAppBottomSheet<T>({
bool showHandle = true,
Color? backgroundColor,
}) {
return showModalBottomSheet<T>(
return showAppModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
isDismissible: isDismissible,
@@ -195,3 +200,128 @@ Future<T?> showAppBottomSheet<T>({
),
);
}
/// Adaptive surface for sheets which already supply their own header/layout.
Future<T?> showAppModalBottomSheet<T>({
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<T>(
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),
],
],
),
);
}
}
+75
View File
@@ -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<bool>? 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),
],
),
),
),
);
}
}
+46
View File
@@ -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,
),
);
}
}
+58 -19
View File
@@ -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<String> onChanged;
final ValueChanged<String>? onChanged;
final VoidCallback onClear;
final ValueChanged<String>? 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<TextEditingValue>(
final field = ValueListenableBuilder<TextEditingValue>(
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,
);
}
}
+5 -1
View File
@@ -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,
),
),
+93
View File
@@ -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<SnackBar, SnackBarClosedReason> 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<double> 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,
),
),
);
}
}
+158
View File
@@ -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<bool>? 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<bool>? 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),
),
);
}
}
+1 -1
View File
@@ -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(
+11 -3
View File
@@ -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<Color> _legendColors() {
+8 -4
View File
@@ -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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
}
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<AudioAnalysisCard> {
}
if (_data == null) {
return Card(
return AppContentCard(
elevation: 0,
color: settingsGroupColor(context),
shape: RoundedRectangleBorder(
+51 -10
View File
@@ -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<String> formats;
final String title;
@@ -93,7 +97,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
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<BatchConvertSheet> {
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<BatchConvertSheet> {
_card(
cs,
child: SwitchListTile(
child: AppSwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.trackConvertKeepOriginal),
subtitle: Text(
@@ -378,7 +390,7 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
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<BatchConvertSheet> {
);
}
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<BatchConvertSheet> {
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),
+4 -3
View File
@@ -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<void>(
showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => BatchProgressDialog._(
@@ -117,7 +118,7 @@ class _BatchProgressDialogState extends State<BatchProgressDialog> {
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<BatchProgressDialog> {
],
),
actions: [
TextButton(
AppDialogAction(
onPressed: widget.onCancel,
child: Text(context.l10n.dialogCancel),
),
+10 -5
View File
@@ -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<Widget> 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<CollectionScaffold> {
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<CollectionScaffold> {
height: widget.isSelectionMode ? _selectionBarHeight : 32,
),
),
SliverToBoxAdapter(child: SizedBox(height: widget.bottomInset)),
NavBarSliverSpacer(bottomInset: widget.bottomInset),
],
),
),
+1 -1
View File
@@ -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,
@@ -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(
+8 -4
View File
@@ -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<bool> showDiscardChangesDialog(
BuildContext context, {
String? content,
}) async {
final result = await showDialog<bool>(
final result = await showAppDialog<bool>(
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),
),
+202 -24
View File
@@ -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<void>(
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<DownloadServicePicker> {
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<DownloadServicePicker> {
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<DownloadServicePicker> {
),
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<DownloadServicePicker> {
),
),
),
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
+7 -5
View File
@@ -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<DuplicateReviewSheet> {
}
Future<bool> _confirmDelete(String message) async {
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
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,
+46
View File
@@ -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),
),
);
}
}
+76 -25
View File
@@ -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<MiniPlayer> createState() => _MiniPlayerState();
@@ -34,8 +41,10 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
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<MiniPlayer> {
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<MiniPlayer> {
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<MiniPlayer> {
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<MiniPlayer> {
),
),
),
const SizedBox(width: 12),
SizedBox(width: compact ? 8 : 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -112,7 +126,16 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
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<MiniPlayer> {
),
),
IconButton(
color: mornye ? colorScheme.onSurface : null,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
@@ -131,16 +155,30 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
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<MiniPlayer> {
),
),
);
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;
}
}
+202
View File
@@ -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<MornyeArtistSurface> createState() => _MornyeArtistSurfaceState();
}
class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
@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<Widget> actions;
final bool showTitle;
final String? listeners;
@override
Widget build(BuildContext context) =>
SliverMainAxisGroup(slivers: buildSlivers(context));
List<Widget> 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,
),
],
],
),
),
),
];
}
}
+207
View File
@@ -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<bool> {
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<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> 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<double>(
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),
),
),
),
),
],
);
},
);
}
}
+613
View File
@@ -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<String> labels;
final int selectedIndex;
final ValueChanged<int> 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<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> 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,
),
),
],
),
),
),
),
),
),
],
),
),
);
}
}
+265
View File
@@ -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<T?> showMornyeContextMenu<T>({
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<T>(
RawDialogRoute<T>(
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<double>(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<MornyeMenuAction> quickActions;
final List<List<MornyeMenuAction>> 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,
),
],
],
),
),
],
),
),
);
}
}
+178
View File
@@ -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<MornyeLandscapePlayer> createState() => _MornyeLandscapePlayerState();
}
class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
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,
),
),
),
),
],
),
),
],
),
),
),
);
}
+61
View File
@@ -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,
);
}
}
+143
View File
@@ -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<MornyePlaybackButton> createState() => _MornyePlaybackButtonState();
}
class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
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<Set<WidgetState>>(
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),
),
),
),
),
);
},
),
);
}
}
+87
View File
@@ -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<MornyePlaybackTime> createState() => _MornyePlaybackTimeState();
}
class _MornyePlaybackTimeState extends State<MornyePlaybackTime> {
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<Offset>(
begin: Offset(0, entering ? direction : -direction),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Text(
clock[index],
key: ValueKey(clock[index]),
style: style,
),
),
),
],
),
);
}
}
@@ -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),
);
}
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more