feat(appearance): add glass clarity controls and directional highlights

This commit is contained in:
zarzet committed 2026-09-30 23:42:20 +07:00
1 parent 9ebf98ff6c
commit ce13e29b71
12 files changed
+526 -225

No files matched your search

+4
View File
@@ -70,6 +70,10 @@
"appearanceAccentBlue": "Blue",
"appearanceAccentPurple": "Purple",
"appearanceAccentPink": "Pink",
"appearanceGlassClarity": "Glass clarity",
"appearanceGlassClaritySubtitle": "Adjust transparency in Mornye menus and navigation. Text contrast is preserved.",
"appearanceGlassTinted": "Tinted",
"appearanceGlassClear": "Clear",
"appearanceUseSystemFont": "Use system font",
"appearanceUseSystemFontSubtitle": "Use your device’s default typeface for app text.",
"@appearanceStyle": {
+4
View File
@@ -70,6 +70,10 @@
"appearanceAccentBlue": "Biru",
"appearanceAccentPurple": "Ungu",
"appearanceAccentPink": "Merah muda",
"appearanceGlassClarity": "Kebeningan kaca",
"appearanceGlassClaritySubtitle": "Atur transparansi menu dan navigasi Mornye. Kontras tulisan tetap dijaga.",
"appearanceGlassTinted": "Pekat",
"appearanceGlassClear": "Bening",
"appearanceUseSystemFont": "Gunakan font sistem",
"appearanceUseSystemFontSubtitle": "Gunakan jenis huruf bawaan perangkat untuk teks aplikasi.",
"@appearanceStyle": {
+21 -2
View File
@@ -7,6 +7,13 @@ const String kUseAmoledKey = 'use_amoled';
const String kThemeStyleKey = 'theme_style';
const String kMornyeAccentKey = 'mornye_accent';
const String kUseSystemFontKey = 'use_system_font';
const String kMornyeGlassClarityKey = 'mornye_glass_clarity';
const double kDefaultMornyeGlassClarity = 0.75;
double normalizeMornyeGlassClarity(num? value) =>
value != null && value.isFinite
? value.toDouble().clamp(0.0, 1.0)
: kDefaultMornyeGlassClarity;
enum AppThemeStyle { material, mornye }
@@ -35,6 +42,7 @@ class ThemeSettings {
final AppThemeStyle style;
final MornyeAccent mornyeAccent;
final bool useSystemFont;
final double mornyeGlassClarity;
const ThemeSettings({
this.themeMode = ThemeMode.system,
@@ -44,6 +52,7 @@ class ThemeSettings {
this.style = AppThemeStyle.material,
this.mornyeAccent = MornyeAccent.red,
this.useSystemFont = false,
this.mornyeGlassClarity = kDefaultMornyeGlassClarity,
});
Color get seedColor => Color(seedColorValue);
@@ -56,6 +65,7 @@ class ThemeSettings {
AppThemeStyle? style,
MornyeAccent? mornyeAccent,
bool? useSystemFont,
double? mornyeGlassClarity,
}) {
return ThemeSettings(
themeMode: themeMode ?? this.themeMode,
@@ -65,6 +75,9 @@ class ThemeSettings {
style: style ?? this.style,
mornyeAccent: mornyeAccent ?? this.mornyeAccent,
useSystemFont: useSystemFont ?? this.useSystemFont,
mornyeGlassClarity: normalizeMornyeGlassClarity(
mornyeGlassClarity ?? this.mornyeGlassClarity,
),
);
}
@@ -76,6 +89,7 @@ class ThemeSettings {
kThemeStyleKey: style.name,
kMornyeAccentKey: mornyeAccent.name,
kUseSystemFontKey: useSystemFont,
kMornyeGlassClarityKey: mornyeGlassClarity,
};
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
@@ -87,6 +101,9 @@ class ThemeSettings {
style: themeStyleFromString(json[kThemeStyleKey] as String?),
mornyeAccent: mornyeAccentFromString(json[kMornyeAccentKey] as String?),
useSystemFont: json[kUseSystemFontKey] as bool? ?? false,
mornyeGlassClarity: normalizeMornyeGlassClarity(
json[kMornyeGlassClarityKey] as num?,
),
);
}
@@ -100,7 +117,8 @@ class ThemeSettings {
other.useAmoled == useAmoled &&
other.style == style &&
other.mornyeAccent == mornyeAccent &&
other.useSystemFont == useSystemFont;
other.useSystemFont == useSystemFont &&
other.mornyeGlassClarity == mornyeGlassClarity;
}
@override
@@ -111,7 +129,8 @@ class ThemeSettings {
useAmoled.hashCode ^
style.hashCode ^
mornyeAccent.hashCode ^
useSystemFont.hashCode;
useSystemFont.hashCode ^
mornyeGlassClarity.hashCode;
}
ThemeMode themeModeFromString(String? value) {
+12
View File
@@ -16,6 +16,9 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) {
style: themeStyleFromString(prefs.getString(kThemeStyleKey)),
mornyeAccent: mornyeAccentFromString(prefs.getString(kMornyeAccentKey)),
useSystemFont: prefs.getBool(kUseSystemFontKey) ?? false,
mornyeGlassClarity: normalizeMornyeGlassClarity(
prefs.getDouble(kMornyeGlassClarityKey),
),
);
}
@@ -51,6 +54,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await prefs.setString(kThemeStyleKey, state.style.name);
await prefs.setString(kMornyeAccentKey, state.mornyeAccent.name);
await prefs.setBool(kUseSystemFontKey, state.useSystemFont);
await prefs.setDouble(kMornyeGlassClarityKey, state.mornyeGlassClarity);
} catch (e) {
debugPrint('Error saving theme settings: $e');
}
@@ -90,4 +94,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
state = state.copyWith(useSystemFont: value);
await _saveToStorage();
}
Future<void> setMornyeGlassClarity(
double value, {
bool persist = true,
}) async {
state = state.copyWith(mornyeGlassClarity: value);
if (persist) await _saveToStorage();
}
}
@@ -111,6 +111,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
.read(themeProvider.notifier)
.setMornyeAccent,
),
const _MornyeGlassClaritySlider(),
],
),
),
@@ -481,6 +482,98 @@ class _ThemePreviewCard extends StatelessWidget {
}
}
class _MornyeGlassClaritySlider extends ConsumerWidget {
const _MornyeGlassClaritySlider();
@override
Widget build(BuildContext context, WidgetRef ref) {
final clarity = ref.watch(
themeProvider.select((settings) => settings.mornyeGlassClarity),
);
final notifier = ref.read(themeProvider.notifier);
final theme = Theme.of(context);
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.appearanceGlassClarity, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.appearanceGlassClaritySubtitle,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
const Color(0xff277f94),
const Color(0xffc69756),
],
),
),
child: Padding(
padding: const EdgeInsets.all(18),
child: MornyeGlassPanel(
radius: 22,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 14,
),
child: Row(
children: [
const Icon(Icons.music_note_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
'Mornye',
style: theme.textTheme.titleMedium,
),
),
const Icon(Icons.play_arrow_rounded),
],
),
),
),
),
),
),
),
Semantics(
label: l10n.appearanceGlassClarity,
child: Slider(
key: const ValueKey('mornye-glass-clarity'),
value: clarity,
divisions: 20,
label: '${(clarity * 100).round()}%',
onChanged: (value) =>
notifier.setMornyeGlassClarity(value, persist: false),
onChangeEnd: notifier.setMornyeGlassClarity,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.appearanceGlassTinted,
style: theme.textTheme.bodySmall,
),
Text(l10n.appearanceGlassClear, style: theme.textTheme.bodySmall),
],
),
],
),
);
}
}
class _MornyeAccentPicker extends StatelessWidget {
const _MornyeAccentPicker({required this.selected, required this.onChanged});
+1
View File
@@ -329,6 +329,7 @@ class _ThemePicker extends ConsumerWidget {
brightness,
accent: settings.mornyeAccent,
useSystemFont: settings.useSystemFont,
glassClarity: settings.mornyeGlassClarity,
)
: brightness == Brightness.dark
? AppTheme.dark(
+2
View File
@@ -23,11 +23,13 @@ class DynamicColorWrapper extends ConsumerWidget {
Brightness.light,
accent: themeSettings.mornyeAccent,
useSystemFont: themeSettings.useSystemFont,
glassClarity: themeSettings.mornyeGlassClarity,
),
MornyeTheme.build(
Brightness.dark,
accent: themeSettings.mornyeAccent,
useSystemFont: themeSettings.useSystemFont,
glassClarity: themeSettings.mornyeGlassClarity,
),
themeSettings.themeMode,
);
+31 -2
View File
@@ -13,11 +13,17 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
this.chromeSurface,
this.accent = MornyeAccent.red,
this.useSystemFont = false,
this.glassClarity = kDefaultMornyeGlassClarity,
});
final Color? chromeSurface;
final MornyeAccent accent;
final bool useSystemFont;
final double glassClarity;
static double glassClarityOf(BuildContext context) =>
Theme.of(context).extension<MornyeTheme>()?.glassClarity ??
kDefaultMornyeGlassClarity;
/// A single translucent fill for controls inside an existing glass surface.
static Color controlFill(BuildContext context, {bool enabled = true}) {
@@ -111,6 +117,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
Color? chromeSurface,
MornyeAccent accent = MornyeAccent.red,
bool useSystemFont = false,
double glassClarity = kDefaultMornyeGlassClarity,
}) {
final key = (
defaultTargetPlatform,
@@ -122,7 +129,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
final cached = _themeCache.remove(key);
if (cached != null) {
_themeCache[key] = cached;
return cached;
return _withGlassClarity(cached, glassClarity);
}
final theme = _build(
brightness,
@@ -134,7 +141,23 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
_themeCache.remove(_themeCache.keys.first);
}
_themeCache[key] = theme;
return theme;
return _withGlassClarity(theme, glassClarity);
}
// Moving the clarity slider only changes the material, not typography or
// seeded colors. Keep the expensive base theme cached independently.
static ThemeData _withGlassClarity(ThemeData theme, double value) {
final clarity = normalizeMornyeGlassClarity(value);
if (theme.extension<MornyeTheme>()!.glassClarity == clarity) return theme;
return theme.copyWith(
extensions: [
for (final extension in theme.extensions.values)
if (extension is MornyeTheme)
extension.copyWith(glassClarity: clarity)
else
extension,
],
);
}
/// Rebuild local surfaces without losing the user's Mornye preferences.
@@ -150,6 +173,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
chromeSurface: chromeSurface,
accent: preferences?.accent ?? MornyeAccent.red,
useSystemFont: preferences?.useSystemFont ?? false,
glassClarity: preferences?.glassClarity ?? kDefaultMornyeGlassClarity,
);
}
@@ -366,10 +390,12 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
Color? chromeSurface,
MornyeAccent? accent,
bool? useSystemFont,
double? glassClarity,
}) => MornyeTheme(
chromeSurface: chromeSurface ?? this.chromeSurface,
accent: accent ?? this.accent,
useSystemFont: useSystemFont ?? this.useSystemFont,
glassClarity: glassClarity ?? this.glassClarity,
);
@override
@@ -379,6 +405,9 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
useSystemFont: t < 0.5
? useSystemFont
: other?.useSystemFont ?? useSystemFont,
glassClarity:
glassClarity +
((other?.glassClarity ?? glassClarity) - glassClarity) * t,
);
}
+150 -89
View File
@@ -237,12 +237,7 @@ class MornyeGlassPanel extends ConsumerWidget {
tintColor: tintColor,
backdropFilter: backdropFilter,
blurEnabled: blur,
child: Material(
color: strongTint
? Theme.of(context).colorScheme.surface.withValues(alpha: 0.60)
: Colors.transparent,
child: child,
),
child: Material(color: Colors.transparent, child: child),
);
}
}
@@ -285,15 +280,19 @@ class MornyeGlass extends StatelessWidget {
/// Keeps floating controls readable over arbitrary album artwork.
final bool strongTint;
/// Uses a single tint instead of layered highlights for translucent surfaces.
/// Base tint before applying the user's glass clarity preference.
final double? tintOpacity;
final Color? tintColor;
final ImageFilter? backdropFilter;
@override
Widget build(BuildContext context) {
final clarity = MornyeTheme.glassClarityOf(context);
final useGlass =
_useLens && blurEnabled && !MediaQuery.highContrastOf(context);
_useLens &&
blurEnabled &&
clarity > 0 &&
!MediaQuery.highContrastOf(context);
Widget lens(Widget child) => Consumer(
// Mid-range Android keeps the frosted surface but not the shader lens.
builder: (context, ref, child) =>
@@ -314,39 +313,33 @@ class MornyeGlass extends StatelessWidget {
}
Widget _liquidLens(Widget child) => Builder(
builder: (context) {
final scheme = Theme.of(context).colorScheme;
final dark = scheme.brightness == Brightness.dark;
return NativeGlassMetrics(
child: 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,
),
builder: (context) => NativeGlassMetrics(
child: LiquidGlassLens(
style: LiquidGlassStyle(
shape: LiquidGlassShape.continuousRoundedRectangle(
cornerRadius: radius,
// The shared rim owns directional highlights, including
// surfaces without a lens. Avoid a second specular border.
borderWidth: 0,
lightIntensity: 0,
),
appearance: const LiquidGlassAppearance(
color: Colors.transparent,
// The surface already blurs the backdrop. Refracting that
// frosted result needs no second Gaussian blur pass.
blur: LiquidGlassBlur(),
),
refraction: const LiquidGlassRefraction(
distortion: 0.02,
distortionWidth: 8,
chromaticAberration: 0,
),
// Only the shader uses window metrics; responsive content and
// decoded artwork retain the surrounding tablet layout scale.
child: MediaQuery(data: MediaQuery.of(context), child: child),
),
);
},
// Only the shader uses window metrics; responsive content and
// decoded artwork retain the surrounding tablet layout scale.
child: MediaQuery(data: MediaQuery.of(context), child: child),
),
),
);
}
@@ -354,6 +347,34 @@ class MornyeGlass extends StatelessWidget {
/// 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);
// Reduce luminance without flattening the backdrop's color differences.
// Subtract 35% of Rec.709 luma from every channel: white is bounded at 0.65,
// while colored artwork stays visible without per-frame pixel readback.
static final _darkBackdrop = ImageFilter.compose(
outer: const ColorFilter.matrix([
0.92559,
-0.25032,
-0.02527,
0,
0,
-0.07441,
0.74968,
-0.02527,
0,
0,
-0.07441,
-0.25032,
0.97473,
0,
0,
0,
0,
0,
1,
0,
]),
inner: _backdropBlur,
);
const _MornyeGlassSurface({
required this.child,
@@ -380,52 +401,27 @@ class _MornyeGlassSurface extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final useBlur = blurEnabled && !MediaQuery.highContrastOf(context);
final clarity = MornyeTheme.glassClarityOf(context);
final useBlur =
blurEnabled && clarity > 0 && !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: tintColor == null ? 0.16 : 0.28)
: 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,
);
// A translucent white tint must not become solid white behind light
// text when accessibility or the device profile disables blur.
final baseTint = (tintColor ?? scheme.surfaceContainerHigh).withValues(
alpha: (tintOpacity ?? (strongTint ? 0.80 : 0.60)) * (dark ? 0.82 : 0.88),
);
final tint = useBlur
? tintColor ?? scheme.surfaceContainerHigh
? Color.alphaBlend(
scheme.surfaceContainerHigh.withValues(alpha: 1 - clarity),
baseTint,
)
: scheme.surfaceContainerHigh;
final surface = DecoratedBox(
decoration: BoxDecoration(
color: tint.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,
),
decoration: BoxDecoration(color: tint, borderRadius: shape),
child: child,
);
return DecoratedBox(
@@ -434,31 +430,29 @@ class _MornyeGlassSurface extends StatelessWidget {
boxShadow: firstInGroup && lastInGroup
? [
BoxShadow(
color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06),
blurRadius: dark ? 18 : 10,
color: Colors.black.withValues(alpha: dark ? 0.14 : 0.08),
blurRadius: 12,
// Clear glass keeps its tint; the shadow belongs outside
// the panel, not underneath its translucent center.
blurStyle: tintColor == null
? BlurStyle.normal
: BlurStyle.outer,
offset: Offset(0, dark ? 4 : 2),
blurStyle: BlurStyle.outer,
offset: const Offset(0, 3),
),
]
: 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: CustomPaint(
foregroundPainter: _MornyeGlassRim(
shape: shape,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
illuminated: useBlur,
),
child: ClipRRect(
borderRadius: shape,
child: useBlur
? BackdropFilter(
filter: backdropFilter ?? _backdropBlur,
filter:
backdropFilter ?? (dark ? _darkBackdrop : _backdropBlur),
child: surface,
)
: surface,
@@ -468,6 +462,73 @@ class _MornyeGlassSurface extends StatelessWidget {
}
}
/// Static, axis-aligned highlights: bright upper/lower inner edges and a thin
/// shadow at the sides. No offscreen layer, extra blur, or per-frame readback.
class _MornyeGlassRim extends CustomPainter {
const _MornyeGlassRim({
required this.shape,
required this.firstInGroup,
required this.lastInGroup,
required this.illuminated,
});
final BorderRadius shape;
final bool firstInGroup;
final bool lastInGroup;
final bool illuminated;
@override
void paint(Canvas canvas, Size size) {
if (size.isEmpty) return;
final bounds = Offset.zero & size;
final outline = shape.toRRect(
Rect.fromLTRB(
0,
firstInGroup ? 0 : -2,
size.width,
lastInGroup ? size.height : size.height + 2,
),
);
canvas.save();
canvas.clipRect(bounds);
canvas.drawRRect(
outline.deflate(0.25),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.5
..color = Colors.black.withValues(alpha: 0.22),
);
if (illuminated) {
canvas.drawRRect(
outline.deflate(0.9),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.18, 0.5, 0.82, 1],
colors: [
firstInGroup ? const Color(0x99ffffff) : Colors.transparent,
const Color(0x18ffffff),
const Color(0x30000000),
const Color(0x18ffffff),
lastInGroup ? const Color(0x80ffffff) : Colors.transparent,
],
).createShader(bounds),
);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant _MornyeGlassRim oldDelegate) =>
shape != oldDelegate.shape ||
firstInGroup != oldDelegate.firstInGroup ||
lastInGroup != oldDelegate.lastInGroup ||
illuminated != oldDelegate.illuminated;
}
/// A searchable category uses the same material as the navigation capsule.
class MornyeFilterChip extends ConsumerWidget {
const MornyeFilterChip({