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
2026-09-19 12:10:10 +07:00
parent 1ebdac8645
commit 63e1a9b9c8
155 changed files with 15707 additions and 2318 deletions
+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),
);
}
}