From 55a390d6793179eb232c9a1bd2994172ddf00f36 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:11:30 +0700 Subject: [PATCH] fix(ui): correct glass shader coordinates on scaled tablets Use native window metrics for glass shaders while preserving scaled layout metrics for their content. Cover fullscreen tablet orientations and resizing to a compact window. Fixes #597 --- lib/widgets/app_switch.dart | 13 +- lib/widgets/mornye_chrome.dart | 295 +++++++++++++------------- lib/widgets/native_glass_metrics.dart | 25 +++ test/native_glass_metrics_test.dart | 106 +++++++++ 4 files changed, 292 insertions(+), 147 deletions(-) create mode 100644 lib/widgets/native_glass_metrics.dart create mode 100644 test/native_glass_metrics_test.dart diff --git a/lib/widgets/app_switch.dart b/lib/widgets/app_switch.dart index 99642979..b15a6471 100644 --- a/lib/widgets/app_switch.dart +++ b/lib/widgets/app_switch.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/native_glass_metrics.dart'; /// One switch style for settings, extension controls and editing sheets. /// Mornye keeps its wide oval thumb even when glass or motion is disabled. @@ -48,11 +49,13 @@ class AppSwitch extends StatelessWidget { if (useGlass) { control = Transform.flip( flipX: Directionality.of(context) == TextDirection.rtl, - child: LiquidGlassSwitch( - value: value, - onChanged: onChanged!, - activeColor: active, - inactiveColor: inactive, + child: NativeGlassMetrics( + child: LiquidGlassSwitch( + value: value, + onChanged: onChanged!, + activeColor: active, + inactiveColor: inactive, + ), ), ); } else { diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 8962e17d..e40e6e41 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/native_glass_metrics.dart'; class MornyeSegmentedControl extends ConsumerWidget { const MornyeSegmentedControl({ @@ -53,59 +54,61 @@ class MornyeSegmentedControl extends ConsumerWidget { child: const SizedBox.expand(), ), ), - LiquidGlassTabBar.withImpeller( - width: constraints.maxWidth, - height: height, - margin: const EdgeInsets.only(bottom: 8), - itemPadding: 4, - selectedIndex: logicalIndex(selectedIndex), - onChanged: (index) => onChanged(logicalIndex(index)), - style: const LiquidGlassStyle( - shape: LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: 32, - borderWidth: 0, - lightIntensity: 0, + NativeGlassMetrics( + child: LiquidGlassTabBar.withImpeller( + width: constraints.maxWidth, + height: height, + margin: const EdgeInsets.only(bottom: 8), + itemPadding: 4, + selectedIndex: logicalIndex(selectedIndex), + onChanged: (index) => onChanged(logicalIndex(index)), + style: const LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: 32, + borderWidth: 0, + lightIntensity: 0, + ), + appearance: LiquidGlassAppearance(), + refraction: LiquidGlassRefraction( + distortion: 0, + chromaticAberration: 0, + ), ), - appearance: LiquidGlassAppearance(), - refraction: LiquidGlassRefraction( - distortion: 0, - chromaticAberration: 0, + pillStyle: LiquidGlassTabPillStyle( + mode: blur && animate + ? LiquidGlassPillMode.impellerOnly + : LiquidGlassPillMode.none, + animated: animate, ), - ), - pillStyle: LiquidGlassTabPillStyle( - mode: blur && animate - ? LiquidGlassPillMode.impellerOnly - : LiquidGlassPillMode.none, - animated: animate, - ), - itemStyle: LiquidGlassTabItemStyle( - selectedColor: scheme.onSurface, - unselectedColor: scheme.onSurface, - iconSize: 0, - iconLabelGap: 0, - labelFontSize: 15, - ), - items: [ - for (final label in visualLabels) - LiquidGlassTabBarItem( - label: label, - iconBuilder: (_, _) => const SizedBox.shrink(), - labelBuilder: (context, state) => Text( - label, - textDirection: rtl - ? TextDirection.rtl - : TextDirection.ltr, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onSurface, - fontWeight: state.selected - ? FontWeight.w600 - : FontWeight.w400, + itemStyle: LiquidGlassTabItemStyle( + selectedColor: scheme.onSurface, + unselectedColor: scheme.onSurface, + iconSize: 0, + iconLabelGap: 0, + labelFontSize: 15, + ), + items: [ + for (final label in visualLabels) + LiquidGlassTabBarItem( + label: label, + iconBuilder: (_, _) => const SizedBox.shrink(), + labelBuilder: (context, state) => Text( + label, + textDirection: rtl + ? TextDirection.rtl + : TextDirection.ltr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + fontWeight: state.selected + ? FontWeight.w600 + : FontWeight.w400, + ), ), ), - ), - ], + ], + ), ), ], ), @@ -235,32 +238,36 @@ class MornyeGlass extends StatelessWidget { tintOpacity: tintOpacity, tintColor: tintColor, child: useGlass - ? LiquidGlassLens( - style: LiquidGlassStyle( - shape: LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: radius, - borderWidth: dark && tintOpacity == null ? 0 : 0.6, - lightIntensity: dark && tintOpacity == null ? 0 : 0.18, - ), - appearance: LiquidGlassAppearance( - color: tintOpacity != null - ? Colors.transparent - : dark - ? scheme.surfaceContainerHigh.withValues(alpha: 0.28) - : Colors.white.withValues( - alpha: strongTint ? 0.20 : 0.55, - ), - // The surface already blurs the backdrop. Refracting that - // frosted result needs no second Gaussian blur pass. - blur: const LiquidGlassBlur(), - ), - refraction: const LiquidGlassRefraction( - distortion: 0.02, - distortionWidth: 8, - chromaticAberration: 0, + ? 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, + ), ), + // 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), ), - child: child, ) : child, ); @@ -494,76 +501,80 @@ class MornyeTabBar extends StatelessWidget { child: const SizedBox.expand(), ), ), - LiquidGlassTabBar.withImpeller( - width: constraints.maxWidth, - height: 64, - margin: const EdgeInsets.only(bottom: 8), - selectedIndex: selectedIndex < 0 ? 0 : selectedIndex, - onChanged: onSelected, - style: LiquidGlassTabBar.defaultStyle.copyWith( - // The frosted base owns the subtle outline. Disable the - // package's default specular rim around the whole capsule. - shape: const LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: 32, - borderWidth: 0, - lightIntensity: 0, - ), - // The sibling surface already supplies tint and blur. Keep - // the lens clear so the moving pill can refract the icons. - appearance: const LiquidGlassAppearance(), - refraction: const LiquidGlassRefraction( - distortion: 0, - chromaticAberration: 0, - ), - ), - itemStyle: LiquidGlassTabItemStyle( - selectedColor: scheme.primary, - unselectedColor: scheme.onSurface, - iconSize: 25, - labelFontSize: 11, - ), - pillStyle: LiquidGlassTabPillStyle( - mode: LiquidGlassPillMode.impellerOnly, - show: selectedIndex >= 0, - color: selectionFill, - animated: true, - // Keep the moving refractive pill, without stacking the - // package's second magnifier lens beneath it. - magnifierPill: const LiquidGlassTabMagnifierPillStyle( - enabled: false, - ), - ), - items: [ - for (final (index, destination) in destinations.indexed) - LiquidGlassTabBarItem( - label: destination.label, - iconBuilder: (context, icon) => IconTheme( - data: IconThemeData( - size: icon.size, - color: icon.selected && selectedIndex >= 0 - ? scheme.primary - : inactiveIconColor, - ), - child: Opacity( - opacity: hiddenIconIndices.contains(index) ? 0 : 1, - child: destination.icon, - ), - ), - labelBuilder: (context, label) => Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - fontSize: label.textStyle.fontSize, - fontWeight: FontWeight.w600, - color: selectedIndex < 0 - ? scheme.onSurface - : label.textStyle.color, - ), - ), + NativeGlassMetrics( + child: LiquidGlassTabBar.withImpeller( + width: constraints.maxWidth, + height: 64, + margin: const EdgeInsets.only(bottom: 8), + selectedIndex: selectedIndex < 0 ? 0 : selectedIndex, + onChanged: onSelected, + style: LiquidGlassTabBar.defaultStyle.copyWith( + // The frosted base owns the subtle outline. Disable the + // package's default specular rim around the whole capsule. + shape: const LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: 32, + borderWidth: 0, + lightIntensity: 0, ), - ], + // The sibling surface already supplies tint and blur. Keep + // the lens clear so the moving pill can refract the icons. + appearance: const LiquidGlassAppearance(), + refraction: const LiquidGlassRefraction( + distortion: 0, + chromaticAberration: 0, + ), + ), + itemStyle: LiquidGlassTabItemStyle( + selectedColor: scheme.primary, + unselectedColor: scheme.onSurface, + iconSize: 25, + labelFontSize: 11, + ), + pillStyle: LiquidGlassTabPillStyle( + mode: LiquidGlassPillMode.impellerOnly, + show: selectedIndex >= 0, + color: selectionFill, + animated: true, + // Keep the moving refractive pill, without stacking the + // package's second magnifier lens beneath it. + magnifierPill: const LiquidGlassTabMagnifierPillStyle( + enabled: false, + ), + ), + items: [ + for (final (index, destination) in destinations.indexed) + LiquidGlassTabBarItem( + label: destination.label, + iconBuilder: (context, icon) => IconTheme( + data: IconThemeData( + size: icon.size, + color: icon.selected && selectedIndex >= 0 + ? scheme.primary + : inactiveIconColor, + ), + child: Opacity( + opacity: hiddenIconIndices.contains(index) + ? 0 + : 1, + child: destination.icon, + ), + ), + labelBuilder: (context, label) => Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontSize: label.textStyle.fontSize, + fontWeight: FontWeight.w600, + color: selectedIndex < 0 + ? scheme.onSurface + : label.textStyle.color, + ), + ), + ), + ], + ), ), // With no selected tab the package still needs an internal // index; handle taps here so diff --git a/lib/widgets/native_glass_metrics.dart b/lib/widgets/native_glass_metrics.dart new file mode 100644 index 00000000..95ed3551 --- /dev/null +++ b/lib/widgets/native_glass_metrics.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +/// Gives glass shaders the actual window's coordinate system. +/// +/// AdaptiveUiScaler changes MediaQuery's size and density for layout and image +/// decoding. The glass renderer already includes that ancestor transform when +/// positioning its shader, so using the scaled density again displaces the +/// effect from its clip (especially near the bottom of a tablet screen). +class NativeGlassMetrics extends StatelessWidget { + const NativeGlassMetrics({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final view = View.of(context); + return MediaQuery( + data: MediaQuery.of(context).copyWith( + size: view.physicalSize / view.devicePixelRatio, + devicePixelRatio: view.devicePixelRatio, + ), + child: child, + ); + } +} diff --git a/test/native_glass_metrics_test.dart b/test/native_glass_metrics_test.dart new file mode 100644 index 00000000..05ffc170 --- /dev/null +++ b/test/native_glass_metrics_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + testWidgets('glass uses window pixels while tablet content keeps its scale', ( + tester, + ) async { + tester.view.devicePixelRatio = 2; + tester.view.padding = const FakeViewPadding(bottom: 40); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPadding); + MediaQueryData? contentMetrics; + + Future pumpAt(Size size) async { + tester.view.physicalSize = size * 2; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + builder: (context, child) => AdaptiveUiScaler(child: child!), + home: Scaffold( + body: Column( + children: [ + MornyeGlass( + blurEnabled: true, + child: Builder( + builder: (context) { + contentMetrics = MediaQuery.of(context); + return const SizedBox(width: 200, height: 50); + }, + ), + ), + MornyeSegmentedControl( + labels: const ['One', 'Two'], + selectedIndex: 0, + onChanged: (_) {}, + ), + AppSwitch(value: true, onChanged: (_) {}), + MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.music_note), + label: 'Library', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + blurEnabled: true, + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // Full-screen tablet in both orientations, compact tablet, then a pop-up + // window and back. Resizing must update the shader without disabling glass. + for (final size in [ + const Size(1400, 876), + const Size(876, 1400), + const Size(700, 1000), + const Size(430, 850), + const Size(1400, 876), + ]) { + await pumpAt(size); + final scale = adaptiveUiScaleForSize(size); + expect(contentMetrics!.size, size / scale); + expect(contentMetrics!.devicePixelRatio, 2 * scale); + expect(contentMetrics!.padding.bottom, closeTo(20 / scale, 0.01)); + for (final type in [ + LiquidGlassLens, + LiquidGlassTabBar, + LiquidGlassSwitch, + ]) { + final glass = find.byType(type); + expect(glass, findsWidgets); + for (final element in glass.evaluate()) { + final metrics = MediaQuery.of(element); + expect(metrics.size, size, reason: '$type at $size'); + expect(metrics.devicePixelRatio, 2, reason: '$type at $size'); + expect(metrics.textScaler, contentMetrics!.textScaler); + expect(metrics.highContrast, contentMetrics!.highContrast); + expect(metrics.disableAnimations, contentMetrics!.disableAnimations); + } + } + final content = tester.getRect(find.byType(MornyeGlass).first); + expect(content.width, closeTo(200 * scale, 0.01)); + expect(content.height, closeTo(50 * scale, 0.01)); + expect(tester.takeException(), isNull); + } + }); +}