feat(appearance): make Mornye glass outlines optional

This commit is contained in:
zarzet committed 2026-10-09 18:07:55 +07:00
1 parent 1b9c35f322
commit 1cae561e6c
14 files changed
+204 -34

No files matched your search

+36 -7
View File
@@ -146,7 +146,13 @@ void main() {
await tester.pump();
}
for (final clarity in [0.6, 1.0]) {
for (final (brightness, clarity, outlines) in [
(Brightness.dark, 0.6, true),
(Brightness.dark, 1.0, true),
(Brightness.light, 0.75, true),
(Brightness.light, 0.75, false),
(Brightness.dark, 0.75, false),
]) {
chrome.expand();
await tester.pumpWidget(
ProviderScope(
@@ -162,16 +168,22 @@ void main() {
mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid),
],
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark, glassClarity: clarity),
theme: MornyeTheme.build(
brightness,
glassClarity: clarity,
glassOutlines: outlines,
),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
extendBody: true,
body: flatBackdrop
? const ColoredBox(
color: Colors.black,
child: SizedBox.expand(),
? ColoredBox(
color: brightness == Brightness.dark
? Colors.black
: Colors.white,
child: const SizedBox.expand(),
)
: const CustomPaint(
painter: _Pattern(),
@@ -229,7 +241,15 @@ void main() {
}
expect(find.byType(MiniPlayer), findsOneWidget);
final player = tester.state(find.byType(MiniPlayer));
final prefix = 'clarity_${(clarity * 100).round()}';
final prefix =
'${brightness.name}_clarity_${(clarity * 100).round()}_outlines_$outlines';
final lenses = tester.widgetList<LiquidGlassLens>(
find.byType(LiquidGlassLens),
);
expect(lenses, isNotEmpty);
for (final lens in lenses) {
expect(lens.style.shape?.borderWidth, outlines ? 0.75 : 0);
}
await capture('${prefix}_expanded');
await halfway(true);
await capture('${prefix}_folding_half');
@@ -331,6 +351,15 @@ void main() {
await touch.cancel();
}
await wait(300);
// Live emulator frames can lag behind the release animation's duration.
for (
var attempt = 0;
capsule() != restingCapsule && attempt < 20;
attempt++
) {
await wait(100);
await tester.pump();
}
expect(capsule(), restingCapsule);
expect(foreground(), restingForeground);
expect(tester.state(find.byType(MiniPlayer)), same(player));
@@ -339,7 +368,7 @@ void main() {
binding.reportData!['mornye_glass_visual'] = {
'scope':
'Production MornyeBottomBar/MiniPlayer over '
'${flatBackdrop ? 'flat black' : 'generated stripes/grid'}; '
'${flatBackdrop ? 'flat light/dark surfaces' : 'generated stripes/grid'}; '
'paused in-memory playback and settings, no persistent data or network.',
'captureSurface':
'${Platform.isAndroid ? 'Android converted Flutter image view' : 'iOS window'}; '
+2
View File
@@ -114,6 +114,8 @@
"appearanceAccentPurple": "Purple",
"appearanceAccentPink": "Pink",
"appearanceGlassClarity": "Glass clarity",
"appearanceGlassOutlines": "Glass outlines",
"appearanceGlassOutlinesSubtitle": "Show decorative edges around Mornye glass. Turning this off keeps blur and refraction.",
"appearanceGlassClaritySubtitle": "Adjust transparency in Mornye menus and navigation. Text contrast is preserved.",
"appearanceGlassTinted": "Tinted",
"appearanceGlassClear": "Clear",
+2
View File
@@ -198,6 +198,8 @@
"appearanceAccentPurple": "Ungu",
"appearanceAccentPink": "Merah muda",
"appearanceGlassClarity": "Kebeningan kaca",
"appearanceGlassOutlines": "Garis tepi kaca",
"appearanceGlassOutlinesSubtitle": "Tampilkan garis dekoratif di tepi kaca Mornye. Mematikannya tetap mempertahankan blur dan pembiasan.",
"appearanceGlassClaritySubtitle": "Atur kebeningan kaca menu dan navigasi Mornye. Kontras teks tetap terjaga.",
"appearanceGlassTinted": "Berwarna",
"appearanceGlassClear": "Bening",
+11 -2
View File
@@ -8,6 +8,7 @@ const String kThemeStyleKey = 'theme_style';
const String kMornyeAccentKey = 'mornye_accent';
const String kUseSystemFontKey = 'use_system_font';
const String kMornyeGlassClarityKey = 'mornye_glass_clarity';
const String kMornyeGlassOutlinesKey = 'mornye_glass_outlines';
const double kDefaultMornyeGlassClarity = 0.75;
double normalizeMornyeGlassClarity(num? value) =>
@@ -43,6 +44,7 @@ class ThemeSettings {
final MornyeAccent mornyeAccent;
final bool useSystemFont;
final double mornyeGlassClarity;
final bool mornyeGlassOutlines;
const ThemeSettings({
this.themeMode = ThemeMode.system,
@@ -53,6 +55,7 @@ class ThemeSettings {
this.mornyeAccent = MornyeAccent.red,
this.useSystemFont = false,
this.mornyeGlassClarity = kDefaultMornyeGlassClarity,
this.mornyeGlassOutlines = true,
});
Color get seedColor => Color(seedColorValue);
@@ -66,6 +69,7 @@ class ThemeSettings {
MornyeAccent? mornyeAccent,
bool? useSystemFont,
double? mornyeGlassClarity,
bool? mornyeGlassOutlines,
}) {
return ThemeSettings(
themeMode: themeMode ?? this.themeMode,
@@ -78,6 +82,7 @@ class ThemeSettings {
mornyeGlassClarity: normalizeMornyeGlassClarity(
mornyeGlassClarity ?? this.mornyeGlassClarity,
),
mornyeGlassOutlines: mornyeGlassOutlines ?? this.mornyeGlassOutlines,
);
}
@@ -90,6 +95,7 @@ class ThemeSettings {
kMornyeAccentKey: mornyeAccent.name,
kUseSystemFontKey: useSystemFont,
kMornyeGlassClarityKey: mornyeGlassClarity,
kMornyeGlassOutlinesKey: mornyeGlassOutlines,
};
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
@@ -104,6 +110,7 @@ class ThemeSettings {
mornyeGlassClarity: normalizeMornyeGlassClarity(
json[kMornyeGlassClarityKey] as num?,
),
mornyeGlassOutlines: json[kMornyeGlassOutlinesKey] as bool? ?? true,
);
}
@@ -118,7 +125,8 @@ class ThemeSettings {
other.style == style &&
other.mornyeAccent == mornyeAccent &&
other.useSystemFont == useSystemFont &&
other.mornyeGlassClarity == mornyeGlassClarity;
other.mornyeGlassClarity == mornyeGlassClarity &&
other.mornyeGlassOutlines == mornyeGlassOutlines;
}
@override
@@ -130,7 +138,8 @@ class ThemeSettings {
style.hashCode ^
mornyeAccent.hashCode ^
useSystemFont.hashCode ^
mornyeGlassClarity.hashCode;
mornyeGlassClarity.hashCode ^
mornyeGlassOutlines.hashCode;
}
ThemeMode themeModeFromString(String? value) {
+10
View File
@@ -19,6 +19,7 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) {
mornyeGlassClarity: normalizeMornyeGlassClarity(
prefs.getDouble(kMornyeGlassClarityKey),
),
mornyeGlassOutlines: prefs.getBool(kMornyeGlassOutlinesKey) ?? true,
);
}
@@ -80,6 +81,10 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
kMornyeGlassClarityKey,
snapshot.mornyeGlassClarity,
);
await prefs.setBool(
kMornyeGlassOutlinesKey,
snapshot.mornyeGlassOutlines,
);
} catch (e) {
debugPrint('Error saving theme settings: $e');
}
@@ -127,4 +132,9 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
_editState((current) => current.copyWith(mornyeGlassClarity: value));
if (persist) await _saveToStorage();
}
Future<void> setMornyeGlassOutlines(bool value) async {
_editState((current) => current.copyWith(mornyeGlassOutlines: value));
await _saveToStorage();
}
}
@@ -113,6 +113,17 @@ class AppearanceSettingsPage extends ConsumerWidget {
.setMornyeAccent,
),
const _MornyeGlassClaritySlider(),
SettingsSwitchItem(
key: const ValueKey('mornye-glass-outlines'),
icon: Icons.rounded_corner,
title: context.l10n.appearanceGlassOutlines,
subtitle: context.l10n.appearanceGlassOutlinesSubtitle,
value: themeSettings.mornyeGlassOutlines,
onChanged: ref
.read(themeProvider.notifier)
.setMornyeGlassOutlines,
showDivider: false,
),
],
),
),
@@ -89,6 +89,18 @@ class SettingsSearchCatalog {
),
],
appearance = [
SettingsSearchEntry(
icon: Icons.rounded_corner,
title: l10n.appearanceGlassOutlines,
subtitle: l10n.appearanceGlassOutlinesSubtitle,
keywords: const [
'mornye',
'liquid glass',
'border',
'rim',
'outline',
],
),
SettingsSearchEntry(
icon: Icons.wallpaper,
title: l10n.appearanceDynamicColor,
+1
View File
@@ -330,6 +330,7 @@ class _ThemePicker extends ConsumerWidget {
accent: settings.mornyeAccent,
useSystemFont: settings.useSystemFont,
glassClarity: settings.mornyeGlassClarity,
glassOutlines: settings.mornyeGlassOutlines,
)
: brightness == Brightness.dark
? AppTheme.dark(
+2
View File
@@ -24,12 +24,14 @@ class DynamicColorWrapper extends ConsumerWidget {
accent: themeSettings.mornyeAccent,
useSystemFont: themeSettings.useSystemFont,
glassClarity: themeSettings.mornyeGlassClarity,
glassOutlines: themeSettings.mornyeGlassOutlines,
),
MornyeTheme.build(
Brightness.dark,
accent: themeSettings.mornyeAccent,
useSystemFont: themeSettings.useSystemFont,
glassClarity: themeSettings.mornyeGlassClarity,
glassOutlines: themeSettings.mornyeGlassOutlines,
),
themeSettings.themeMode,
);
+25 -5
View File
@@ -14,17 +14,22 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
this.accent = MornyeAccent.red,
this.useSystemFont = false,
this.glassClarity = kDefaultMornyeGlassClarity,
this.glassOutlines = true,
});
final Color? chromeSurface;
final MornyeAccent accent;
final bool useSystemFont;
final double glassClarity;
final bool glassOutlines;
static double glassClarityOf(BuildContext context) =>
Theme.of(context).extension<MornyeTheme>()?.glassClarity ??
kDefaultMornyeGlassClarity;
static bool glassOutlinesOf(BuildContext context) =>
Theme.of(context).extension<MornyeTheme>()?.glassOutlines ?? true;
/// A single translucent fill for controls inside an existing glass surface.
static Color controlFill(BuildContext context, {bool enabled = true}) {
final theme = Theme.of(context);
@@ -121,6 +126,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
MornyeAccent accent = MornyeAccent.red,
bool useSystemFont = false,
double glassClarity = kDefaultMornyeGlassClarity,
bool glassOutlines = true,
}) {
final key = (
defaultTargetPlatform,
@@ -132,7 +138,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
final cached = _themeCache.remove(key);
if (cached != null) {
_themeCache[key] = cached;
return _withGlassClarity(cached, glassClarity);
return _withGlassPreferences(cached, glassClarity, glassOutlines);
}
final theme = _build(
brightness,
@@ -144,19 +150,27 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
_themeCache.remove(_themeCache.keys.first);
}
_themeCache[key] = theme;
return _withGlassClarity(theme, glassClarity);
return _withGlassPreferences(theme, glassClarity, glassOutlines);
}
// 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) {
static ThemeData _withGlassPreferences(
ThemeData theme,
double value,
bool outlines,
) {
final clarity = normalizeMornyeGlassClarity(value);
if (theme.extension<MornyeTheme>()!.glassClarity == clarity) return theme;
final preferences = theme.extension<MornyeTheme>()!;
if (preferences.glassClarity == clarity &&
preferences.glassOutlines == outlines) {
return theme;
}
return theme.copyWith(
extensions: [
for (final extension in theme.extensions.values)
if (extension is MornyeTheme)
extension.copyWith(glassClarity: clarity)
extension.copyWith(glassClarity: clarity, glassOutlines: outlines)
else
extension,
],
@@ -177,6 +191,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
accent: preferences?.accent ?? MornyeAccent.red,
useSystemFont: preferences?.useSystemFont ?? false,
glassClarity: preferences?.glassClarity ?? kDefaultMornyeGlassClarity,
glassOutlines: preferences?.glassOutlines ?? true,
);
}
@@ -394,11 +409,13 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
MornyeAccent? accent,
bool? useSystemFont,
double? glassClarity,
bool? glassOutlines,
}) => MornyeTheme(
chromeSurface: chromeSurface ?? this.chromeSurface,
accent: accent ?? this.accent,
useSystemFont: useSystemFont ?? this.useSystemFont,
glassClarity: glassClarity ?? this.glassClarity,
glassOutlines: glassOutlines ?? this.glassOutlines,
);
@override
@@ -411,6 +428,9 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
glassClarity:
glassClarity +
((other?.glassClarity ?? glassClarity) - glassClarity) * t,
glassOutlines: t < 0.5
? glassOutlines
: other?.glassOutlines ?? glassOutlines,
);
}
+18 -9
View File
@@ -184,6 +184,7 @@ class MornyeGlass extends ConsumerWidget {
clarity > 0 &&
!MediaQuery.highContrastOf(context);
final dark = scheme.brightness == Brightness.dark;
final outlines = MornyeTheme.glassOutlinesOf(context);
final shape = BorderRadius.vertical(
top: firstInGroup ? Radius.circular(radius) : Radius.zero,
bottom: lastInGroup ? Radius.circular(radius) : Radius.zero,
@@ -280,13 +281,18 @@ class MornyeGlass extends ConsumerWidget {
: null,
),
child: CustomPaint(
foregroundPainter: _MornyeGlassRim(
shape: shape,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
illuminated: translucent && (!useLens || highlightBorder),
emphasized: highlightBorder,
),
// The optical lens already draws its own rim. Avoid a second dark
// contact stroke on top, particularly visible against light pages.
foregroundPainter: outlines && (!useLens || highlightBorder)
? _MornyeGlassRim(
shape: shape,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
illuminated: translucent && (!useLens || highlightBorder),
emphasized: highlightBorder,
dark: dark,
)
: null,
child: ClipRRect(
borderRadius: shape,
child: useLens
@@ -324,6 +330,7 @@ class _MornyeGlassRim extends CustomPainter {
required this.lastInGroup,
required this.illuminated,
required this.emphasized,
required this.dark,
});
final BorderRadius shape;
@@ -331,6 +338,7 @@ class _MornyeGlassRim extends CustomPainter {
final bool lastInGroup;
final bool illuminated;
final bool emphasized;
final bool dark;
@override
void paint(Canvas canvas, Size size) {
@@ -352,7 +360,7 @@ class _MornyeGlassRim extends CustomPainter {
..style = PaintingStyle.stroke
..strokeWidth = 0.5
// A thin contact edge must not paint a shadow under the whole glass.
..color = Colors.black.withValues(alpha: 0.5),
..color = Colors.black.withValues(alpha: dark ? 0.5 : 0.16),
);
if (illuminated) {
canvas.drawRRect(
@@ -387,7 +395,8 @@ class _MornyeGlassRim extends CustomPainter {
firstInGroup != oldDelegate.firstInGroup ||
lastInGroup != oldDelegate.lastInGroup ||
illuminated != oldDelegate.illuminated ||
emphasized != oldDelegate.emphasized;
emphasized != oldDelegate.emphasized ||
dark != oldDelegate.dark;
}
/// A searchable category uses the same material as the navigation capsule.
+6 -3
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
/// A bounded package lens without a captured-screen view or a touch ticker.
class MornyeLiquidBackdrop extends StatelessWidget {
@@ -28,14 +29,16 @@ class MornyeLiquidBackdrop extends StatelessWidget {
Widget build(BuildContext context) {
if (interaction && progress <= 0) return child;
final effect = interaction ? progress : 1.0;
final outlines = MornyeTheme.glassOutlinesOf(context);
final dark = Theme.of(context).brightness == Brightness.dark;
return LiquidGlassLens(
style: LiquidGlassStyle(
shape: LiquidGlassShape.roundedRectangle(
cornerRadius: borderRadius.topLeft.x,
borderWidth: 0.75,
borderWidth: outlines ? 0.75 : 0,
lightDirection: 0,
lightIntensity: 0.25 * effect,
lightColor: Colors.white,
lightIntensity: outlines ? (dark ? 0.25 : 0.12) * effect : 0,
lightColor: dark ? Colors.white : const Color(0x88ffffff),
borderType: const OpticalBorder(
ambientIntensity: 0.05,
lightSpread: 0.2,
+17 -8
View File
@@ -5,6 +5,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart';
Future<ui.FragmentProgram>? _selectionMaskProgram;
@@ -247,10 +248,14 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
: 0.08,
),
borderRadius: BorderRadius.circular(_pillRadius),
border: Border.all(
color: scheme.onSurface.withValues(alpha: 0.05),
width: 0.5,
),
border: MornyeTheme.glassOutlinesOf(context)
? Border.all(
color: scheme.onSurface.withValues(
alpha: 0.05,
),
width: 0.5,
)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
@@ -408,10 +413,14 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
? 0.24
: 0.08),
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.6 * progress),
width: 0.75,
),
border: MornyeTheme.glassOutlinesOf(context)
? Border.all(
color: Colors.white.withValues(
alpha: 0.6 * progress,
),
width: 0.75,
)
: null,
),
child: const SizedBox.expand(),
),
+51
View File
@@ -11,6 +11,19 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
void main() {
test(
'old themes retain outlines and new themes serialize the preference',
() {
expect(ThemeSettings.fromJson({}).mornyeGlassOutlines, isTrue);
const settings = ThemeSettings(mornyeGlassOutlines: false);
expect(ThemeSettings.fromJson(settings.toJson()), settings);
expect(
settings.copyWith(mornyeGlassClarity: 1).mornyeGlassOutlines,
isFalse,
);
expect(settings, isNot(const ThemeSettings()));
},
);
test('glass clarity restores safely from old and invalid settings', () {
expect(
ThemeSettings.fromJson({}).mornyeGlassClarity,
@@ -90,6 +103,44 @@ void main() {
}
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
testWidgets(
'glass outlines toggle survives restart and local themes ($mode)',
(tester) async {
SharedPreferences.setMockInitialValues({
kThemeStyleKey: 'mornye',
kThemeModeKey: mode.name,
});
final prefs = await SharedPreferences.getInstance();
await openSettings(tester, prefs);
final toggle = find.byKey(const ValueKey('mornye-glass-outlines'));
await tester.ensureVisible(toggle);
await tester.pumpAndSettle();
await tester.tap(find.text('Glass outlines'));
await tester.pumpAndSettle();
expect(prefs.getBool(kMornyeGlassOutlinesKey), isFalse);
final context = tester.element(toggle);
expect(MornyeTheme.glassOutlinesOf(context), isFalse);
final local = MornyeTheme.fromContext(
context,
brightness: Brightness.dark,
);
expect(local.extension<MornyeTheme>()!.glassOutlines, isFalse);
expect(
local.extension<MornyeTheme>()!.glassClarity,
kDefaultMornyeGlassClarity,
);
await tester.pumpWidget(const SizedBox());
await openSettings(tester, prefs);
expect(MornyeTheme.glassOutlinesOf(tester.element(toggle)), isFalse);
await tester.ensureVisible(toggle);
await tester.pumpAndSettle();
await tester.tap(find.text('Glass outlines'));
await tester.pumpAndSettle();
expect(prefs.getBool(kMornyeGlassOutlinesKey), isTrue);
expect(tester.takeException(), isNull);
},
);
testWidgets('glass clarity previews live and survives restart ($mode)', (
tester,
) async {