mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
feat(appearance): add glass clarity controls and directional highlights
This commit is contained in:
1 parent
9ebf98ff6c
commit
ce13e29b71
12 files changed
+526
-225
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ class _ThemePicker extends ConsumerWidget {
|
||||
brightness,
|
||||
accent: settings.mornyeAccent,
|
||||
useSystemFont: settings.useSystemFont,
|
||||
glassClarity: settings.mornyeGlassClarity,
|
||||
)
|
||||
: brightness == Brightness.dark
|
||||
? AppTheme.dark(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -10,6 +10,23 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
void main() {
|
||||
test('glass clarity restores safely from old and invalid settings', () {
|
||||
expect(
|
||||
ThemeSettings.fromJson({}).mornyeGlassClarity,
|
||||
kDefaultMornyeGlassClarity,
|
||||
);
|
||||
for (final (saved, expected) in [
|
||||
(0, 0.0),
|
||||
(1, 1.0),
|
||||
(-1, 0.0),
|
||||
(2, 1.0),
|
||||
(double.nan, kDefaultMornyeGlassClarity),
|
||||
]) {
|
||||
final settings = ThemeSettings.fromJson({kMornyeGlassClarityKey: saved});
|
||||
expect(settings.mornyeGlassClarity, expected);
|
||||
expect(ThemeSettings.fromJson(settings.toJson()), settings);
|
||||
}
|
||||
});
|
||||
test('custom accents keep text readable in both appearances', () {
|
||||
double contrast(Color first, Color second) {
|
||||
final a = first.computeLuminance();
|
||||
@@ -69,6 +86,43 @@ void main() {
|
||||
}
|
||||
|
||||
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
||||
testWidgets('glass clarity previews live and survives restart ($mode)', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
kThemeStyleKey: 'mornye',
|
||||
kThemeModeKey: mode.name,
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await openSettings(tester, prefs);
|
||||
final slider = find.byKey(const ValueKey('mornye-glass-clarity'));
|
||||
await tester.ensureVisible(slider);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(slider, const Offset(160, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Slider>(slider).value, 1);
|
||||
expect(prefs.getDouble(kMornyeGlassClarityKey), 1);
|
||||
final context = tester.element(slider);
|
||||
expect(MornyeTheme.glassClarityOf(context), 1);
|
||||
expect(
|
||||
MornyeTheme.fromContext(
|
||||
context,
|
||||
brightness: Brightness.dark,
|
||||
).extension<MornyeTheme>()!.glassClarity,
|
||||
1,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await openSettings(tester, prefs);
|
||||
expect(tester.widget<Slider>(slider).value, 1);
|
||||
await tester.ensureVisible(slider);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(slider, const Offset(-320, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Slider>(slider).value, 0);
|
||||
expect(prefs.getDouble(kMornyeGlassClarityKey), 0);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Mornye accent updates live and survives restart ($mode)', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -140,7 +194,11 @@ void main() {
|
||||
tester.element(page),
|
||||
).textTheme.bodyLarge!.fontFamily;
|
||||
final setting = find.text('Use system font');
|
||||
await tester.ensureVisible(setting);
|
||||
await tester.scrollUntilVisible(
|
||||
setting,
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(setting);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -164,7 +222,11 @@ void main() {
|
||||
Theme.of(tester.element(page)).textTheme.bodyLarge!.fontFamily,
|
||||
systemFamily,
|
||||
);
|
||||
await tester.ensureVisible(setting);
|
||||
await tester.scrollUntilVisible(
|
||||
setting,
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(setting);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -43,6 +43,7 @@ void main() {
|
||||
Color? backdrop,
|
||||
Color? chromeSurface,
|
||||
Widget? body,
|
||||
double glassClarity = 0.75,
|
||||
ValueNotifier<int>? activeTab,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(393, 760);
|
||||
@@ -68,7 +69,11 @@ void main() {
|
||||
backdropBlurEnabledProvider.overrideWithValue(blur),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness, chromeSurface: chromeSurface),
|
||||
theme: MornyeTheme.build(
|
||||
brightness,
|
||||
chromeSurface: chromeSurface,
|
||||
glassClarity: glassClarity,
|
||||
),
|
||||
builder: (_, child) => RepaintBoundary(key: capture, child: child),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -389,85 +394,88 @@ void main() {
|
||||
}
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
for (final backdrop in [
|
||||
Colors.white,
|
||||
Colors.black,
|
||||
if (brightness == Brightness.dark) albumBlue,
|
||||
]) {
|
||||
testWidgets(
|
||||
'floating glass stays legible in $brightness over $backdrop',
|
||||
(tester) async {
|
||||
await pumpShell(
|
||||
tester,
|
||||
blur: true,
|
||||
brightness: brightness,
|
||||
backdrop: backdrop,
|
||||
chromeSurface: backdrop == albumBlue ? albumBlue : null,
|
||||
);
|
||||
final artist = find.text('Artist');
|
||||
// The glass renderer also builds a copy for the refractive pill.
|
||||
final libraryIcon = find
|
||||
.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.byIcon(Icons.music_note),
|
||||
)
|
||||
.first;
|
||||
final artistRect = tester.getRect(artist);
|
||||
final iconRect = tester.getRect(libraryIcon);
|
||||
final foregrounds = [
|
||||
tester.widget<Text>(artist).style!.color!,
|
||||
IconTheme.of(tester.element(libraryIcon)).color!,
|
||||
];
|
||||
final samples = [
|
||||
Offset(artistRect.left + 8, artistRect.bottom + 2),
|
||||
Offset(iconRect.right + 8, iconRect.center.dy),
|
||||
];
|
||||
final backgrounds = await tester.runAsync(() async {
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.byKey(capture),
|
||||
for (final clarity in [0.0, 0.75, 1.0]) {
|
||||
for (final backdrop in [
|
||||
Colors.white,
|
||||
Colors.black,
|
||||
if (brightness == Brightness.dark) albumBlue,
|
||||
]) {
|
||||
testWidgets(
|
||||
'floating glass stays legible in $brightness over $backdrop (clarity: $clarity)',
|
||||
(tester) async {
|
||||
await pumpShell(
|
||||
tester,
|
||||
blur: true,
|
||||
brightness: brightness,
|
||||
glassClarity: clarity,
|
||||
backdrop: backdrop,
|
||||
chromeSurface: backdrop == albumBlue ? albumBlue : null,
|
||||
);
|
||||
final image = await boundary.toImage();
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final colors = <Color>[];
|
||||
for (final point in samples) {
|
||||
final offset =
|
||||
(point.dy.floor() * image.width + point.dx.floor()) * 4;
|
||||
colors.add(
|
||||
Color.fromARGB(
|
||||
bytes.getUint8(offset + 3),
|
||||
bytes.getUint8(offset),
|
||||
bytes.getUint8(offset + 1),
|
||||
bytes.getUint8(offset + 2),
|
||||
),
|
||||
final artist = find.text('Artist');
|
||||
// The glass renderer also builds a copy for the refractive pill.
|
||||
final libraryIcon = find
|
||||
.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.byIcon(Icons.music_note),
|
||||
)
|
||||
.first;
|
||||
final artistRect = tester.getRect(artist);
|
||||
final iconRect = tester.getRect(libraryIcon);
|
||||
final foregrounds = [
|
||||
tester.widget<Text>(artist).style!.color!,
|
||||
IconTheme.of(tester.element(libraryIcon)).color!,
|
||||
];
|
||||
final samples = [
|
||||
Offset(artistRect.left + 8, artistRect.bottom + 2),
|
||||
Offset(iconRect.right + 8, iconRect.center.dy),
|
||||
];
|
||||
final backgrounds = await tester.runAsync(() async {
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.byKey(capture),
|
||||
);
|
||||
final image = await boundary.toImage();
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
))!;
|
||||
final colors = <Color>[];
|
||||
for (final point in samples) {
|
||||
final offset =
|
||||
(point.dy.floor() * image.width + point.dx.floor()) * 4;
|
||||
colors.add(
|
||||
Color.fromARGB(
|
||||
bytes.getUint8(offset + 3),
|
||||
bytes.getUint8(offset),
|
||||
bytes.getUint8(offset + 1),
|
||||
bytes.getUint8(offset + 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
image.dispose();
|
||||
return colors;
|
||||
});
|
||||
for (var index = 0; index < foregrounds.length; index++) {
|
||||
if (backdrop == albumBlue) {
|
||||
final glass = HSLColor.fromColor(backgrounds![index]);
|
||||
final page = HSLColor.fromColor(albumBlue);
|
||||
expect(glass.hue, closeTo(page.hue, 8));
|
||||
expect(glass.saturation, greaterThan(page.saturation * 0.7));
|
||||
}
|
||||
final luminances = [
|
||||
foregrounds[index].computeLuminance(),
|
||||
backgrounds![index].computeLuminance(),
|
||||
]..sort();
|
||||
final contrast =
|
||||
(luminances.last + 0.05) / (luminances.first + 0.05);
|
||||
expect(
|
||||
contrast,
|
||||
greaterThanOrEqualTo(index == 0 ? 4.5 : 3),
|
||||
reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon',
|
||||
);
|
||||
}
|
||||
image.dispose();
|
||||
return colors;
|
||||
});
|
||||
for (var index = 0; index < foregrounds.length; index++) {
|
||||
if (backdrop == albumBlue) {
|
||||
final glass = HSLColor.fromColor(backgrounds![index]);
|
||||
final page = HSLColor.fromColor(albumBlue);
|
||||
expect(glass.hue, closeTo(page.hue, 8));
|
||||
expect(glass.saturation, greaterThan(page.saturation * 0.7));
|
||||
}
|
||||
final luminances = [
|
||||
foregrounds[index].computeLuminance(),
|
||||
backgrounds![index].computeLuminance(),
|
||||
]..sort();
|
||||
final contrast =
|
||||
(luminances.last + 0.05) / (luminances.first + 0.05);
|
||||
expect(
|
||||
contrast,
|
||||
greaterThanOrEqualTo(index == 0 ? 4.5 : 3),
|
||||
reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon',
|
||||
);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
@@ -26,6 +27,7 @@ void main() {
|
||||
bool lowEnd = false,
|
||||
Color? backgroundColor,
|
||||
GlobalKey? capture,
|
||||
double glassClarity = kDefaultMornyeGlassClarity,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -37,7 +39,7 @@ void main() {
|
||||
backdropBlurEnabledProvider.overrideWithValue(false),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness),
|
||||
theme: MornyeTheme.build(brightness, glassClarity: glassClarity),
|
||||
builder: (context, child) => RepaintBoundary(
|
||||
key: capture,
|
||||
child: MediaQuery(
|
||||
@@ -137,59 +139,63 @@ void main() {
|
||||
}))!;
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
for (final background in [
|
||||
Colors.white,
|
||||
Colors.black,
|
||||
const Color(0xff6c3a22),
|
||||
]) {
|
||||
testWidgets('menu labels stay readable over $background ($brightness)', (
|
||||
tester,
|
||||
) async {
|
||||
final originalDisableShadows = debugDisableShadows;
|
||||
debugDisableShadows = false;
|
||||
try {
|
||||
final capture = GlobalKey();
|
||||
await openMenu(
|
||||
tester,
|
||||
anchor: const Rect.fromLTWH(330, 144, 44, 44),
|
||||
brightness: brightness,
|
||||
backgroundColor: background,
|
||||
capture: capture,
|
||||
onResult: (_) {},
|
||||
);
|
||||
final menu = tester.getRect(find.byType(MornyeContextMenu));
|
||||
final pixels = await samplePixels(tester, capture, [
|
||||
for (final x in [menu.left + 8, menu.right - 8])
|
||||
for (final fraction in [0.25, 0.5, 0.75])
|
||||
Offset(x, menu.top + menu.height * fraction),
|
||||
]);
|
||||
final foreground = MornyeTheme.build(
|
||||
brightness,
|
||||
).colorScheme.onSurface;
|
||||
for (final inside in pixels) {
|
||||
// Busy lyrics and bright artwork must not wash out either label.
|
||||
for (final textColor in [
|
||||
foreground,
|
||||
Color.alphaBlend(foreground.withValues(alpha: 0.96), inside),
|
||||
]) {
|
||||
final luminances = [
|
||||
inside.computeLuminance(),
|
||||
textColor.computeLuminance(),
|
||||
]..sort();
|
||||
final contrast =
|
||||
(luminances.last + 0.05) / (luminances.first + 0.05);
|
||||
expect(contrast, greaterThanOrEqualTo(4.5));
|
||||
for (final clarity in [0.0, kDefaultMornyeGlassClarity, 1.0]) {
|
||||
for (final background in [
|
||||
Colors.white,
|
||||
Colors.black,
|
||||
const Color(0xff6c3a22),
|
||||
]) {
|
||||
testWidgets(
|
||||
'menu labels stay readable over $background ($brightness, clarity: $clarity)',
|
||||
(tester) async {
|
||||
final originalDisableShadows = debugDisableShadows;
|
||||
debugDisableShadows = false;
|
||||
try {
|
||||
final capture = GlobalKey();
|
||||
await openMenu(
|
||||
tester,
|
||||
anchor: const Rect.fromLTWH(330, 144, 44, 44),
|
||||
brightness: brightness,
|
||||
glassClarity: clarity,
|
||||
backgroundColor: background,
|
||||
capture: capture,
|
||||
onResult: (_) {},
|
||||
);
|
||||
final menu = tester.getRect(find.byType(MornyeContextMenu));
|
||||
final pixels = await samplePixels(tester, capture, [
|
||||
for (final x in [menu.left + 8, menu.right - 8])
|
||||
for (final fraction in [0.25, 0.5, 0.75])
|
||||
Offset(x, menu.top + menu.height * fraction),
|
||||
]);
|
||||
final foreground = MornyeTheme.build(
|
||||
brightness,
|
||||
).colorScheme.onSurface;
|
||||
for (final inside in pixels) {
|
||||
// Busy lyrics and bright artwork must not wash out either label.
|
||||
for (final textColor in [
|
||||
foreground,
|
||||
Color.alphaBlend(foreground.withValues(alpha: 0.96), inside),
|
||||
]) {
|
||||
final luminances = [
|
||||
inside.computeLuminance(),
|
||||
textColor.computeLuminance(),
|
||||
]..sort();
|
||||
final contrast =
|
||||
(luminances.last + 0.05) / (luminances.first + 0.05);
|
||||
expect(contrast, greaterThanOrEqualTo(4.5));
|
||||
}
|
||||
// The panel remains even at both edges without an offset lens.
|
||||
expect(inside.r, closeTo(pixels.first.r, 0.015));
|
||||
expect(inside.g, closeTo(pixels.first.g, 0.015));
|
||||
expect(inside.b, closeTo(pixels.first.b, 0.015));
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
} finally {
|
||||
debugDisableShadows = originalDisableShadows;
|
||||
}
|
||||
// The panel remains even at both edges without an offset lens.
|
||||
expect(inside.r, closeTo(pixels.first.r, 0.015));
|
||||
expect(inside.g, closeTo(pixels.first.g, 0.015));
|
||||
expect(inside.b, closeTo(pixels.first.b, 0.015));
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
} finally {
|
||||
debugDisableShadows = originalDisableShadows;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('menu stays on screen near a bottom edge ($brightness)', (
|
||||
|
||||
Reference in new issue
Block a user