From 45d258e5826b911c358a6a52ea6f0196f1f3bdb1 Mon Sep 17 00:00:00 2001 From: TiaraBasori Date: Mon, 26 Oct 2026 00:22:12 +0800 Subject: [PATCH 1/2] fix(shell): restore the native iOS glass host with Telegram's tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS showed no liquid glass because the native surface had been retired for a Flutter BackdropFilter bar, and before that its tint was derived from the bar's own surface colour: in the light palette navBar equals the background white, so any alpha either composites to nothing or, pushed high enough to see, becomes a fill that hides the material's refraction — the exact 'still no glass' report. The tint now follows Telegram iOS (GlassBackgroundComponent): plain white at 0.10 in light and 0.025 in dark, strictly a whisper so the material stays visible. The capsule's edge, which Telegram derives by clamping backdrop luminance through a private UIKit class, is drawn instead by the same Flutter hairline contour and shadow the Flutter glass bar uses, so no private API or swizzle ships. The surface host is the reviewed public-API NativeGlassSurface (UIGlassEffect on iOS 26+, UIBlurEffect before), with the documented no-op fixed: a tint change installs a fresh effect because UIGlassEffect resolves its tint when the material is built. Geometry, tabs, pill, and gestures stay app-owned in Flutter, so the compact phone bar and the tablet sidebar column share one layout in either host; the restored native geometry suite pins the split column, home-indicator containment, RTL, and the host-mirror rect. --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 3 + ios/Runner/NativeGlassSurface.swift | 118 +++++++ lib/app/main_tab_view.dart | 97 ++++- lib/app/native_glass_surface.dart | 106 ++++++ test/glass_bottom_bar_geometry_test.dart | 127 ++++--- test/native_glass_bar_geometry_test.dart | 427 +++++++++++++++++++++++ test/native_glass_surface_test.dart | 188 ++++++++++ 8 files changed, 1007 insertions(+), 63 deletions(-) create mode 100644 ios/Runner/NativeGlassSurface.swift create mode 100644 lib/app/native_glass_surface.dart create mode 100644 test/native_glass_bar_geometry_test.dart create mode 100644 test/native_glass_surface_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3dd392aa8..749a9c649 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 9A4F20F231E1000100000001 /* NowPlayingBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20F131E1000100000001 /* NowPlayingBridge.swift */; }; 9A4F20EA31E1000100000001 /* CommunicationNotificationBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20E931E1000100000001 /* CommunicationNotificationBridge.swift */; }; 9A4F20EC31E1000100000001 /* ApplePCCBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */; }; + AC39B11231E1000100000001 /* NativeGlassSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC39B11331E1000100000001 /* NativeGlassSurface.swift */; }; A17D0FF131E1000100000001 /* HandoffBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17D0FF031E1000100000001 /* HandoffBridge.swift */; }; A685F7A8DE8A8339A7CFE48A /* RunnerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8FADD48E0FE94429FDE1912 /* RunnerUITests.swift */; }; B005C7BA5D2714CB149B4B95 /* MithkaBlue.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0CC7151EAA772074B2F1F159 /* MithkaBlue.icon */; }; @@ -134,6 +135,7 @@ 9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplePCCBridge.swift; sourceTree = ""; }; 9AE1F80607B45379F5468F27 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; A17D0FF031E1000100000001 /* HandoffBridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HandoffBridge.swift; path = ../../apple/HandoffBridge.swift; sourceTree = ""; }; + AC39B11331E1000100000001 /* NativeGlassSurface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeGlassSurface.swift; sourceTree = ""; }; A257B729519AB240FBD5E29F /* MithkaPurple.icon */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.iconcomposer.icon; path = MithkaPurple.icon; sourceTree = ""; }; B02F5C04823E6F1B8B4DC318 /* NotificationCommunicationContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCommunicationContent.swift; sourceTree = ""; }; C145FCC43A8CB4B5D827E2D6 /* MithkaPixel.icon */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.iconcomposer.icon; path = MithkaPixel.icon; sourceTree = ""; }; @@ -273,6 +275,7 @@ 9A4F20F131E1000100000001 /* NowPlayingBridge.swift */, 9A4F20E931E1000100000001 /* CommunicationNotificationBridge.swift */, 9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */, + AC39B11331E1000100000001 /* NativeGlassSurface.swift */, A17D0FF031E1000100000001 /* HandoffBridge.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -699,6 +702,7 @@ 9A4F20F231E1000100000001 /* NowPlayingBridge.swift in Sources */, 9A4F20EA31E1000100000001 /* CommunicationNotificationBridge.swift in Sources */, 9A4F20EC31E1000100000001 /* ApplePCCBridge.swift in Sources */, + AC39B11231E1000100000001 /* NativeGlassSurface.swift in Sources */, A17D0FF131E1000100000001 /* HandoffBridge.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5f44aa7ac..26d820a44 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -196,6 +196,9 @@ import UserNotifications guard !didRegisterFlutterPlugins else { return } didRegisterFlutterPlugins = true GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "NativeGlassSurface") { + registrar.register(NativeGlassSurfaceFactory(registrar: registrar), withId: "mithka/native_glass") + } let windowGeometryChannel = FlutterMethodChannel( name: "mithka/window_geometry", binaryMessenger: engineBridge.applicationRegistrar.messenger() diff --git a/ios/Runner/NativeGlassSurface.swift b/ios/Runner/NativeGlassSurface.swift new file mode 100644 index 000000000..95f4deab1 --- /dev/null +++ b/ios/Runner/NativeGlassSurface.swift @@ -0,0 +1,118 @@ +import Flutter +import UIKit + +/// A glass-surface-only platform view behind the app-owned tab row: +/// +/// - On iOS 26+ the glass is the public `UIGlassEffect(style: .regular)` inside +/// a `UIVisualEffectView`, styled with `overrideUserInterfaceStyle` and +/// `tintColor` from the active theme. +/// - Before iOS 26 it uses a system material `UIBlurEffect`. +/// - The view draws no tabs, icons, or badges, and disables user interaction so +/// every tap lands on the Flutter tab controls above it. +@MainActor +final class NativeGlassSurfaceFactory: NSObject, FlutterPlatformViewFactory { + private let registrar: FlutterPluginRegistrar + + init(registrar: FlutterPluginRegistrar) { + self.registrar = registrar + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeGlassSurface(frame: frame, id: viewId, registrar: registrar, arguments: args) + } +} + +@MainActor +private final class NativeGlassSurface: NSObject, FlutterPlatformView { + private let host: GlassSurfaceHost + private let channel: FlutterMethodChannel + + init(frame: CGRect, id: Int64, registrar: FlutterPluginRegistrar, arguments: Any?) { + host = GlassSurfaceHost(frame: frame) + channel = FlutterMethodChannel( + name: "mithka/native_glass/\(id)", binaryMessenger: registrar.messenger() + ) + super.init() + if let configuration = arguments as? [String: Any] { host.apply(configuration) } + channel.setMethodCallHandler { [weak self] call, result in + guard let self, call.method == "update", + let configuration = call.arguments as? [String: Any] + else { + result(FlutterMethodNotImplemented) + return + } + self.host.apply(configuration) + result(nil) + } + } + + func view() -> UIView { host } + + deinit { channel.setMethodCallHandler(nil) } +} + +@MainActor +private final class GlassSurfaceHost: UIView { + private let effectView = UIVisualEffectView(effect: nil) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + isUserInteractionEnabled = false + effectView.isUserInteractionEnabled = false + effectView.layer.masksToBounds = true + addSubview(effectView) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(_ configuration: [String: Any]) { + let dark = configuration["dark"] as? Bool == true + overrideUserInterfaceStyle = dark ? .dark : .light + effectView.overrideUserInterfaceStyle = dark ? .dark : .light + let cornerRadius = (configuration["radius"] as? NSNumber)?.doubleValue ?? 0 + effectView.layer.cornerRadius = cornerRadius + + if #available(iOS 26.0, *) { + var tint: UIColor? + if let argb = configuration["tint"] as? NSNumber { + let value = argb.uint32Value + tint = UIColor( + red: CGFloat((value >> 16) & 255) / 255, + green: CGFloat((value >> 8) & 255) / 255, + blue: CGFloat(value & 255) / 255, + alpha: CGFloat((value >> 24) & 255) / 255 + ) + } + // UIGlassEffect resolves its tint when the material is built, so + // mutating the installed effect is a no-op assignment: a tint change + // installs a fresh effect instead. + if let current = effectView.effect as? UIGlassEffect, current.tintColor == tint { + // Same tint: keep the built material. + } else { + let glass = UIGlassEffect(style: .regular) + glass.tintColor = tint + glass.isInteractive = false + effectView.effect = glass + } + } else { + let desiredStyle: UIBlurEffect.Style = dark ? .systemMaterialDark : .systemMaterialLight + effectView.effect = UIBlurEffect(style: desiredStyle) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + effectView.frame = bounds + } +} diff --git a/lib/app/main_tab_view.dart b/lib/app/main_tab_view.dart index 114e349af..a9102b084 100644 --- a/lib/app/main_tab_view.dart +++ b/lib/app/main_tab_view.dart @@ -62,6 +62,7 @@ import 'desktop_utility_window.dart'; import 'detail_content_reveal.dart'; import 'horizontal_safe_viewport.dart'; import 'liquid_glass_bottom_bar.dart'; +import 'native_glass_surface.dart'; import 'primary_chat_launcher.dart'; import 'unread_badge_model.dart'; @@ -2159,6 +2160,53 @@ double homeIndicatorBarClearance(BuildContext context) { return math.min(reported, kHomeIndicatorClearance); } +/// The silhouette the native glass capsule cannot give itself. +/// +/// iOS 26's material derives its visible edge from a backdrop-luminance clamp +/// that only private UIKit reaches (Telegram swizzles `_UISDFBackdropView`; +/// App Store Guideline 2.5.1 scans binaries for those class-name strings, so +/// it is off-limits here). The public alternative: draw the edge ourselves. +/// This frame wraps the native surface and paints the same hairline contour +/// and drop shadow the Flutter glass bar uses — a dark 0.6pt outline (5.5% +/// black in light, 34% in dark) around the stadium, foreground so the glass +/// clipped inside cannot cover it — while the surface keeps the refraction. +class _GlassCapsuleFrame extends StatelessWidget { + const _GlassCapsuleFrame({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final dark = colors.navBar.computeLuminance() < 0.5; + const black = Color(0xFF000000); + const radius = BorderRadius.all(Radius.circular(AppRadius.pill)); + final contour = black.withValues(alpha: dark ? 0.34 : 0.055); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: [ + BoxShadow( + color: black.withValues(alpha: dark ? 0.24 : 0.09), + blurRadius: 20, + offset: const Offset(0, 5), + ), + ], + ), + child: DecoratedBox( + // Foreground so the contour sits on the capsule's outer edge instead + // of being covered by the glass clipped inside it. + position: DecorationPosition.foreground, + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: contour, width: 0.6), + ), + child: child, + ), + ); + } +} + /// Shared tab controls with an optional glass surface. class _MainBottomBar extends StatelessWidget { const _MainBottomBar({ @@ -2429,11 +2477,50 @@ class _MainBottomBar extends StatelessWidget { ); final controls = controlsWithBottomInset(0.0); if (glass) { - // One Flutter glass bar serves every target, including iOS: the shell - // blurs its own layer tree, so the bar refracts the content that - // actually scrolls behind it. The row, its selection pill, and every - // gesture stay app-owned too, which keeps the compact phone bar and the - // tablet split shell's sidebar column on one geometry source. + // iOS hosts the real system Liquid Glass: a native surface draws only + // the glass (public UIGlassEffect on iOS 26+, system material blur + // before), while the app-owned row, its selection pill, and every + // gesture stay in Flutter above the surface — one geometry source in + // any host, window bottom bar or sidebar column. The tint follows + // Telegram iOS (white 0.10 light / 0.025 dark) so the material itself + // stays visible; the Flutter-drawn contour and shadow below give the + // capsule its edge on white. + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + // A platform view has no insets of its own, so the host repeats the + // exact containment chain the Flutter glass bar applies: the surface + // stops at the bottom safe area instead of reaching under the home + // indicator, and the shared lateral/top/capsule margins keep the row + // inside the stadium at the same rect either host draws it at. + return SafeArea( + key: const ValueKey('liquid-glass-bottom-bar'), + top: false, + minimum: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 0), + child: _GlassCapsuleFrame( + child: NativeGlassSurface( + child: Padding( + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + Positioned.fill( + child: GlassSelectionIndicator( + selection: selection, + itemCount: items.length, + ), + ), + controls, + ], + ), + ), + ), + ), + ), + ); + } + // Every other target keeps the Flutter glass bar: the shell blurs its + // own layer tree, so the bar refracts the content that actually scrolls + // behind it. return LiquidGlassBottomBar( selection: selection, itemCount: items.length, diff --git a/lib/app/native_glass_surface.dart b/lib/app/native_glass_surface.dart new file mode 100644 index 000000000..3fc24d259 --- /dev/null +++ b/lib/app/native_glass_surface.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../theme/app_theme.dart'; + +/// The tint the native glass host installs, as 32-bit ARGB. +/// +/// Telegram iOS's liquid-glass surfaces tint `UIGlassEffect` with plain white +/// at 0.10 in light mode and 0.025 in dark (GlassBackgroundComponent's +/// `TintColor.nativeEffect`). The tint must stay a whisper: a tint derived +/// from the bar's own surface colour is invisible by construction in this +/// app's light palette (`navBar == background == 0xFFFFFF`), and pushing that +/// alpha high enough to see turns the tint into a fill that hides the +/// material's refraction — the bar reads as a flat translucent slab, which is +/// exactly the "no glass at all" report. White at Telegram's alpha leaves the +/// material visible while giving it the faint brightening the system glass +/// expects; the capsule's visible edge comes from the Flutter-drawn hairline +/// contour and shadow, not from the tint. +int nativeGlassTint(bool dark) => + const Color(0xFFFFFFFF).withValues(alpha: dark ? 0.025 : 0.10).toARGB32(); + +/// A native Liquid Glass surface behind app-owned tab content: the UIKit view +/// draws only the glass (public `UIGlassEffect` on iOS 26+, a system material +/// `UIBlurEffect` before), while Flutter draws the tabs, the selection pill, +/// and the badges on top and owns every gesture, so one surface serves the +/// phone bottom bar and the tablet sidebar column alike — there are no system +/// slot metrics to fight. +/// +/// The platform view is transparent to the widget tree: the child paints above +/// it and wins hit testing, while the view's own opaque hit region keeps the +/// scrollable underneath from receiving touches through the glass. +/// +/// Because `UIGlassEffect` resolves its tint when the material is built, a +/// theme change installs a fresh effect instead of mutating the current one +/// (which is a no-op assignment). +class NativeGlassSurface extends StatefulWidget { + const NativeGlassSurface({super.key, required this.child}); + + /// The app-owned tab row drawn above the glass. + final Widget child; + + @override + State createState() => _NativeGlassSurfaceState(); +} + +class _NativeGlassSurfaceState extends State { + MethodChannel? _channel; + + Map _configuration() { + final dark = context.colors.background.computeLuminance() < 0.5; + return { + 'tint': nativeGlassTint(dark), + 'dark': dark, + 'radius': AppRadius.pill, + 'rtl': Directionality.of(context) == TextDirection.rtl, + }; + } + + @override + void didUpdateWidget(NativeGlassSurface oldWidget) { + super.didUpdateWidget(oldWidget); + _update(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _update(); + } + + void _update() { + final channel = _channel; + if (channel != null) { + unawaited(channel.invokeMethod('update', _configuration())); + } + } + + @override + void dispose() { + _channel?.setMethodCallHandler(null); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Stack( + key: const ValueKey('native-glass-surface'), + textDirection: Directionality.of(context), + children: [ + Positioned.fill( + child: UiKitView( + viewType: 'mithka/native_glass', + creationParamsCodec: const StandardMessageCodec(), + creationParams: _configuration(), + onPlatformViewCreated: (id) { + final channel = MethodChannel('mithka/native_glass/$id'); + _channel = channel; + _update(); + }, + ), + ), + widget.child, + ], + ); +} diff --git a/test/glass_bottom_bar_geometry_test.dart b/test/glass_bottom_bar_geometry_test.dart index 3d3463d52..5231916fd 100644 --- a/test/glass_bottom_bar_geometry_test.dart +++ b/test/glass_bottom_bar_geometry_test.dart @@ -1,13 +1,16 @@ // // glass_bottom_bar_geometry_test.dart // -// The app's glass bar is a Flutter widget on every target, iOS included: +// The app's Flutter glass bar is the host on every target EXCEPT iOS: // BackdropFilter blurs the app's own layer tree behind the capsule, and the // app-owned row, its pill indicator, and every gesture stay in Flutter. One // path therefore serves the compact phone bottom bar and the tablet split // shell's sidebar column — the old "system bar paints its capsule against the // window, not this column" failure mode cannot recur, because nothing -// system-drawn is positioned relative to tab slots anymore. +// system-drawn is positioned relative to tab slots anymore. iOS hosts the +// system Liquid Glass instead; its geometry suite is +// native_glass_bar_geometry_test.dart, and its mirror test asserts both +// hosts draw the bar at the same rect. // // The bar keeps its window containment: the capsule stops inside the bottom // safe area instead of reaching under the home indicator, and the row keeps @@ -75,10 +78,10 @@ void main() { ); }); - testWidgets('iOS split shell draws the glass bar with app-owned tabs', ( + testWidgets('non-iOS split shell draws the glass bar with app-owned tabs', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { await _pumpShell(tester, const Size(900, 800)); // The Flutter glass bar is present, with no platform view behind it. expect(find.byKey(_barKey), findsOneWidget); @@ -108,10 +111,10 @@ void main() { }); }); - testWidgets('compact iOS window draws the Flutter glass, taps stay alive', ( + testWidgets('compact window draws the Flutter glass, taps stay alive', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { await _pumpShell(tester, _phoneSize); expect(find.byKey(_barKey), findsOneWidget); expect(find.byType(UiKitView), findsNothing); @@ -143,10 +146,10 @@ void main() { // containment the other bar paths apply, so on a phone window the glass (and // the 62px row above it) reached y=844 and drew under the home indicator // instead of stopping at y=810. - testWidgets('iOS glass capsule stays clear of the home indicator', ( + testWidgets('glass capsule stays clear of the home indicator', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { final homeIndicatorTop = _phoneSize.height - _phoneBottomInset; await _pumpShell( tester, @@ -172,25 +175,30 @@ void main() { }); }); - // The fix requirement in one assertion: whatever the target platform, the - // row and the capsule land on the same rect. - testWidgets('one glass geometry serves the iOS and Android hosts', ( + // The fix requirement in one assertion: the iOS native host and this + // Flutter host land on the same rect (the native suite runs the mirror + // direction; here the Flutter bar is pinned directly). + testWidgets('compact glass geometry is stable across window insets', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { const insets = FakeViewPadding(bottom: _phoneBottomInset); await _pumpShell(tester, _phoneSize, insets: insets); - final iosCapsule = _capsuleRect(tester); - final iosSlot = tester.getRect(find.byKey(_firstSlotKey)); - final iosPill = tester.getRect(find.byKey(_indicatorKey)); + final insetCapsule = _capsuleRect(tester); + final insetSlot = tester.getRect(find.byKey(_firstSlotKey)); + final insetPill = tester.getRect(find.byKey(_indicatorKey)); - // Same window, non-iOS target: the same Flutter glass bar is the host. - debugDefaultTargetPlatformOverride = TargetPlatform.android; - await _pumpShell(tester, _phoneSize, insets: insets); + await _pumpShell(tester, _phoneSize); expect(find.byType(LiquidGlassBottomBar), findsOneWidget); - expect(_capsuleRect(tester), iosCapsule); - expect(tester.getRect(find.byKey(_firstSlotKey)), iosSlot); - expect(tester.getRect(find.byKey(_indicatorKey)), iosPill); + expect(_capsuleRect(tester).height, insetCapsule.height); + expect( + tester.getRect(find.byKey(_firstSlotKey)).height, + insetSlot.height, + ); + expect( + tester.getRect(find.byKey(_indicatorKey)).height, + insetPill.height, + ); expect(tester.takeException(), isNull); await _dispose(tester); }); @@ -199,7 +207,7 @@ void main() { testWidgets('narrow phone keeps the glass capsule inside its insets', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { const size = Size(320, 568); await _pumpShell( tester, @@ -222,7 +230,7 @@ void main() { }); testWidgets('split shell column honors the bottom inset too', (tester) async { - await onIOS(() async { + await onAndroid(() async { // No camera inset and no usable split width, so the glass bar stays in // the narrow sidebar column. await _pumpShell( @@ -244,7 +252,7 @@ void main() { testWidgets('RTL glass bar keeps containment and mirrors its slots', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { final homeIndicatorTop = _phoneSize.height - _phoneBottomInset; await _pumpShell( tester, @@ -265,41 +273,44 @@ void main() { }); }); - // This PR's actual regression pin: the iOS bar used to be a UIKit platform - // view whose visual effect sampled the window composite, and the bar's own - // near-opaque surface tint made it read as a flat white strip. The bar must - // be composited by Flutter itself — a BackdropFilter over the app's layer - // tree, and no UiKitView at all. - testWidgets('iOS glass bar is composited by Flutter, not a platform view', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - try { - await _pumpShell(tester, _phoneSize); - expect(find.byType(LiquidGlassBottomBar), findsOneWidget); - final backdrop = find.descendant( - of: find.byKey(_barKey), - matching: find.byType(BackdropFilter), - ); - expect(backdrop, findsOneWidget); - final filter = tester.widget(backdrop); - expect(filter.enabled, isTrue); - expect(filter.filter.toString(), contains('ImageFilter.blur(24.0, 24.0')); - expect(find.byType(UiKitView), findsNothing); - await _dispose(tester); - } finally { - // The test framework asserts this debug override is unset before the - // test body completes, so it cannot wait for tearDown. - debugDefaultTargetPlatformOverride = null; - } - }); + // The Flutter host's own pin: on every non-iOS target the glass bar is + // composited by Flutter itself — a BackdropFilter over the app's layer + // tree, and no UiKitView at all. (iOS hosts the native surface; that pin + // lives in native_glass_bar_geometry_test.dart.) + testWidgets( + 'non-iOS glass bar is composited by Flutter, not a platform view', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await _pumpShell(tester, _phoneSize); + expect(find.byType(LiquidGlassBottomBar), findsOneWidget); + final backdrop = find.descendant( + of: find.byKey(_barKey), + matching: find.byType(BackdropFilter), + ); + expect(backdrop, findsOneWidget); + final filter = tester.widget(backdrop); + expect(filter.enabled, isTrue); + expect( + filter.filter.toString(), + contains('ImageFilter.blur(24.0, 24.0'), + ); + expect(find.byType(UiKitView), findsNothing); + await _dispose(tester); + } finally { + // The test framework asserts this debug override is unset before the + // test body completes, so it cannot wait for tearDown. + debugDefaultTargetPlatformOverride = null; + } + }, + ); // Every glass slot is an icon-only target. With the setting on no label // is drawn in the bar; its accessible name stays on AppInteractiveSurface. testWidgets('glass bar draws no tab label and keeps its semantic names', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { await _pumpShell(tester, _phoneSize); final bar = find.byKey(_barKey); expect(bar, findsOneWidget); @@ -343,7 +354,7 @@ void main() { testWidgets('classic bar keeps its tab labels when the setting is off', ( tester, ) async { - await onIOS(() async { + await onAndroid(() async { await _pumpShell(tester, _phoneSize, liquidGlass: false); final bar = find.byKey(const ValueKey('classic-bottom-bar')); expect(bar, findsOneWidget); @@ -378,7 +389,7 @@ void main() { }); }); testWidgets('capture the real glass shell for visual review', (tester) async { - await onIOS(() async { + await onAndroid(() async { addTearDown( tester.binding.platformDispatcher.clearPlatformBrightnessTestValue, ); @@ -438,10 +449,10 @@ Rect _capsuleRect(WidgetTester tester) { return tester.getRect(finder); } -/// Runs [body] with iOS as the target platform, clearing the override before +/// Runs [body] with a non-iOS target platform, clearing the override before /// the test body returns (the framework asserts it is unset at teardown). -Future onIOS(Future Function() body) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; +Future onAndroid(Future Function() body) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; try { await body(); } finally { diff --git a/test/native_glass_bar_geometry_test.dart b/test/native_glass_bar_geometry_test.dart new file mode 100644 index 000000000..42d6eac00 --- /dev/null +++ b/test/native_glass_bar_geometry_test.dart @@ -0,0 +1,427 @@ +// +// native_glass_bar_geometry_test.dart +// +// The iOS glass bar hosts a native Liquid Glass surface whose glass never +// draws tabs or selection: the app-owned row, its pill indicator, and every +// gesture stay in Flutter on top of the surface. One path therefore serves the +// compact phone bottom bar and the tablet split shell's sidebar column — the +// old "system bar paints its capsule against the window, not this column" +// failure mode cannot recur, because nothing system-drawn is positioned +// relative to tab slots anymore. +// +// The native host keeps the same window containment as the Flutter glass bar: +// the surface stops inside the bottom safe area instead of reaching under the +// home indicator, and the row keeps the shared lateral, top, and capsule +// insets, so both hosts draw the bar at the same rect. +// +// Under flutter test the real UIKit view never exists, so the whole file +// installs a mock for the platform-view creation channel and the per-view +// method channel; the mock only records and answers the way the native side +// would (nothing to answer). +// + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mithka/app/chat_deep_link_controller.dart'; +import 'package:mithka/app/liquid_glass_bottom_bar.dart'; +import 'package:mithka/app/main_tab_view.dart'; +import 'package:mithka/auth/account_store.dart'; +import 'package:mithka/auth/auth_manager.dart'; +import 'package:mithka/components/drawer_controller.dart' as dc; +import 'package:mithka/l10n/app_locale_controller.dart'; +import 'package:mithka/l10n/app_localizations.dart'; +import 'package:mithka/settings/translation_controller.dart'; +import 'package:mithka/tdlib/td_client.dart'; +import 'package:mithka/theme/app_theme.dart'; +import 'package:mithka/theme/theme_controller.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _surfaceKey = ValueKey('native-glass-surface'); +const _indicatorKey = ValueKey('liquid-glass-selection'); +const _firstSlotKey = ValueKey('bottom-tab-0'); + +/// An iPhone 14-style window: 390x844 with the 34px home-indicator inset. +const _phoneSize = Size(390, 844); +const _phoneBottomInset = 34.0; + +/// The bar geometry both glass hosts share: the 62px control row inside a 5px +/// capsule inset, with 14px lateral and 8px top margins, and a 10px gap above +/// the surface's own bottom (the Flutter bar's `minimum` for the safe area). +const _rowHeight = 62.0; +const _capsuleInset = 5.0; +const _surfaceHeight = _rowHeight + 2 * _capsuleInset; +const _surfaceBottomGap = 10.0; +const _sideMargin = 14.0; + +void main() { + final updates = StreamController>.broadcast(); + tearDownAll(() async { + await TdClient.shared.closeProxy(); + await updates.close(); + }); + setUpAll(() { + TdClient.shared.configureProxy( + TdClientProxyTransport( + accountSlot: 0, + query: (request) async => _response(request), + send: (_) async {}, + updates: updates.stream, + ), + ); + }); + + testWidgets('iOS split shell hosts the native glass with app-owned tabs', ( + tester, + ) async { + await onIOS(() async { + installMockGlassSurface(tester); + await _pumpShell(tester, const Size(900, 800)); + // The native surface is present behind the app-owned bar. + expect(find.byKey(_surfaceKey), findsOneWidget); + expect(find.byType(UiKitView), findsOneWidget); + // The selection pill and the tab slots are all Flutter-drawn, so the + // pill still covers the selected tab exactly inside the narrow sidebar + // column — the geometry no longer depends on where UIKit thinks the + // window-bottom bar is. + final indicator = find.byKey(_indicatorKey); + expect(indicator, findsOneWidget); + final slots = find.byKey(_firstSlotKey); + expect(tester.getRect(indicator), tester.getRect(slots)); + // A window with no bell keeps the shared capsule gap below the surface + // and draws no phantom bar of its own. + final surface = tester.getRect(find.byKey(_surfaceKey)); + expect(surface.bottom, 800 - _surfaceBottomGap); + expect(surface.height, _surfaceHeight); + expect( + tester.getRect(slots).bottom, + 800 - _surfaceBottomGap - _capsuleInset, + ); + await _dispose(tester); + }); + }); + + testWidgets('compact iOS window hosts the native glass, taps stay alive', ( + tester, + ) async { + await onIOS(() async { + installMockGlassSurface(tester); + await _pumpShell(tester, _phoneSize); + expect(find.byKey(_surfaceKey), findsOneWidget); + expect(find.byType(UiKitView), findsOneWidget); + final indicator = find.byKey(_indicatorKey); + final slots = find.byKey(_firstSlotKey); + expect(tester.getRect(indicator), tester.getRect(slots)); + // An uninset window still leaves the shared capsule gap at its bottom. + expect( + tester.getRect(find.byKey(_surfaceKey)).bottom, + _phoneSize.height - _surfaceBottomGap, + ); + // Tapping a tab still selects it: the row above the glass owns gestures. + await tester.tap(find.byKey(const ValueKey('bottom-tab-2'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect( + tester.getRect(find.byKey(_indicatorKey)), + tester.getRect(find.byKey(const ValueKey('bottom-tab-2'))), + ); + await _dispose(tester); + }); + }); + + // Regression: the iOS host returned the platform surface without the + // SafeArea containment the other two bar paths apply, so on a phone window + // the surface (and the 62px row above it) reached y=844 and drew under the + // home indicator instead of stopping at y=810. + testWidgets('iOS glass surface stays clear of the home indicator', ( + tester, + ) async { + await onIOS(() async { + installMockGlassSurface(tester); + final homeIndicatorTop = _phoneSize.height - _phoneBottomInset; + await _pumpShell( + tester, + _phoneSize, + insets: const FakeViewPadding(bottom: _phoneBottomInset), + ); + final surface = find.byKey(_surfaceKey); + expect(surface, findsOneWidget); + final surfaceRect = tester.getRect(surface); + // The glass stops at or above the home indicator's top edge... + expect(surfaceRect.bottom, lessThanOrEqualTo(homeIndicatorTop)); + // ...but it is still the bottom bar, not a surface collapsed by the fix. + expect(surfaceRect.bottom, homeIndicatorTop); + expect(surfaceRect.height, _surfaceHeight); + expect(surfaceRect.left, _sideMargin); + expect(surfaceRect.right, _phoneSize.width - _sideMargin); + expect(tester.getRect(find.byType(UiKitView)), surfaceRect); + // The tab controls stay above the home-indicator region too. + final slot = find.byKey(_firstSlotKey); + expect(tester.getRect(slot).bottom, lessThanOrEqualTo(homeIndicatorTop)); + expect(tester.getRect(slot).bottom, homeIndicatorTop - _capsuleInset); + expect(tester.getRect(slot), tester.getRect(find.byKey(_indicatorKey))); + expect(tester.takeException(), isNull); + await _dispose(tester); + }); + }); + + // The fix requirement in one assertion: whatever the platform host, the row + // and the surface land on the same rect the Flutter glass bar uses. + testWidgets('native host mirrors the Flutter glass bar geometry', ( + tester, + ) async { + await onIOS(() async { + installMockGlassSurface(tester); + const insets = FakeViewPadding(bottom: _phoneBottomInset); + await _pumpShell(tester, _phoneSize, insets: insets); + final nativeSurface = tester.getRect(find.byKey(_surfaceKey)); + final nativeSlot = tester.getRect(find.byKey(_firstSlotKey)); + + // Same window, non-iOS target: the Flutter glass bar is the host. + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await _pumpShell(tester, _phoneSize, insets: insets); + final bar = find.byType(LiquidGlassBottomBar); + expect(bar, findsOneWidget); + expect(find.byKey(_surfaceKey), findsNothing); + final stadium = find.descendant( + of: bar, + matching: find.byWidgetPredicate( + (widget) => + widget is ClipRRect && + widget.borderRadius == BorderRadius.circular(AppRadius.pill), + ), + ); + expect(stadium, findsOneWidget); + expect(tester.getRect(stadium), nativeSurface); + expect(tester.getRect(find.byKey(_firstSlotKey)), nativeSlot); + expect(tester.getRect(find.byKey(_indicatorKey)), nativeSlot); + expect(tester.takeException(), isNull); + await _dispose(tester); + }); + }); + + testWidgets('narrow phone keeps the native surface inside its insets', ( + tester, + ) async { + await onIOS(() async { + installMockGlassSurface(tester); + const size = Size(320, 568); + await _pumpShell( + tester, + size, + insets: const FakeViewPadding(top: 47, bottom: 34), + ); + final surfaceRect = tester.getRect(find.byKey(_surfaceKey)); + expect(surfaceRect.bottom, lessThanOrEqualTo(size.height - 34)); + // The status-bar inset never leaks into the bar's own height. + expect(surfaceRect.height, _surfaceHeight); + // The lateral margins never push the row outside the window. + expect(surfaceRect.left, _sideMargin); + expect(surfaceRect.right, size.width - _sideMargin); + final slot = find.byKey(_firstSlotKey); + expect(tester.getRect(slot).bottom, lessThanOrEqualTo(size.height - 34)); + expect(tester.getRect(slot), tester.getRect(find.byKey(_indicatorKey))); + expect(tester.takeException(), isNull); + await _dispose(tester); + }); + }); + + testWidgets('split shell column honors the bottom inset too', (tester) async { + await onIOS(() async { + installMockGlassSurface(tester); + // No camera inset and no usable split width, so the glass bar stays in + // the narrow sidebar column. + await _pumpShell( + tester, + const Size(900, 800), + insets: const FakeViewPadding(bottom: 34), + ); + final surfaceRect = tester.getRect(find.byKey(_surfaceKey)); + expect(surfaceRect.bottom, lessThanOrEqualTo(800 - 34)); + expect(surfaceRect.height, _surfaceHeight); + final slot = find.byKey(_firstSlotKey); + expect(tester.getRect(slot).bottom, lessThanOrEqualTo(800 - 34)); + expect(tester.getRect(slot), tester.getRect(find.byKey(_indicatorKey))); + expect(tester.takeException(), isNull); + await _dispose(tester); + }); + }); + + testWidgets('RTL glass host keeps containment and the channel flag', ( + tester, + ) async { + await onIOS(() async { + final mock = installMockGlassSurface(tester); + final homeIndicatorTop = _phoneSize.height - _phoneBottomInset; + await _pumpShell( + tester, + _phoneSize, + insets: const FakeViewPadding(bottom: _phoneBottomInset), + textDirection: TextDirection.rtl, + ); + expect(mock.createdParams, isNotEmpty); + for (final params in mock.createdParams) { + expect(params['rtl'], isTrue); + } + final surfaceRect = tester.getRect(find.byKey(_surfaceKey)); + expect(surfaceRect.bottom, lessThanOrEqualTo(homeIndicatorTop)); + expect(surfaceRect.height, _surfaceHeight); + final slot = find.byKey(_firstSlotKey); + expect(tester.getRect(slot).bottom, lessThanOrEqualTo(homeIndicatorTop)); + // Both the row and the pill mirror: the first slot sits on the right. + expect(tester.getRect(slot), tester.getRect(find.byKey(_indicatorKey))); + expect(tester.getRect(slot).left, greaterThan(_phoneSize.width / 2)); + expect(tester.takeException(), isNull); + await _dispose(tester); + }); + }); +} + +/// Mocks platform-view creation and the per-view channel for the whole test. +/// Under flutter test the real UIKit glass never exists, so the mock answers +/// the way the native side would (nothing to answer) while recording the +/// creation parameters each surface was configured with. +class GlassSurfaceMock { + GlassSurfaceMock(this._messenger); + + final TestDefaultBinaryMessenger _messenger; + final List> createdParams = []; + final Map _channels = {}; + + void install() { + _messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final arguments = call.arguments as Map; + final id = arguments['id'] as int; + createdParams.add( + const StandardMessageCodec().decodeMessage( + ByteData.sublistView(arguments['params'] as Uint8List), + ) + as Map, + ); + final channel = MethodChannel('mithka/native_glass/$id'); + _channels[id] = channel; + _messenger.setMockMethodCallHandler(channel, (call) async => null); + } + return null; + }); + } + + void dispose() { + _messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in _channels.values) { + _messenger.setMockMethodCallHandler(channel, null); + } + _channels.clear(); + } +} + +/// Installs the mock for the current test and tears it down with the test. +GlassSurfaceMock installMockGlassSurface(WidgetTester tester) { + final mock = GlassSurfaceMock(tester.binding.defaultBinaryMessenger); + mock.install(); + addTearDown(mock.dispose); + return mock; +} + +/// Runs [body] with iOS as the target platform, clearing the override before +/// the test body returns (the framework asserts it is unset at teardown). +Future onIOS(Future Function() body) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +Future _pumpShell( + WidgetTester tester, + Size size, { + FakeViewPadding insets = FakeViewPadding.zero, + TextDirection textDirection = TextDirection.ltr, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + tester.view.padding = insets; + tester.view.viewPadding = insets; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + SharedPreferences.setMockInitialValues({ + 'showChannelsTab': true, + 'showMomentsTab': true, + 'communitiesEnabled': false, + 'liquidGlassBottomBar': true, + }); + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + final accounts = AccountStore(prefs); + final auth = AuthManager(); + final translation = TranslationController(prefs); + final drawer = dc.DrawerController(); + final deepLinks = ChatDeepLinkController.shared; + deepLinks.consumePending(); + + addTearDown(theme.dispose); + addTearDown(accounts.dispose); + addTearDown(translation.dispose); + addTearDown(drawer.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: theme), + ChangeNotifierProvider.value( + value: AppLocaleController(prefs), + ), + ChangeNotifierProvider.value(value: accounts), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: translation), + ChangeNotifierProvider.value(value: deepLinks), + ChangeNotifierProvider.value(value: drawer), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData( + brightness: Brightness.light, + extensions: [AppColors.light], + ), + builder: (context, child) => + Directionality(textDirection: textDirection, child: child!), + home: const MainSplitRootView(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); +} + +Future _dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + // Chat-list cache warming uses delayed no-op guards after disposal. + await tester.pump(const Duration(seconds: 6)); +} + +Map _response(Map request) => + switch (request['@type']) { + 'getChats' => {'@type': 'chats', 'chat_ids': []}, + 'getMe' => {'@type': 'user', 'id': 1, 'first_name': 'Test'}, + 'getConnectionState' => {'@type': 'connectionStateReady'}, + _ => {'@type': 'ok'}, + }; diff --git a/test/native_glass_surface_test.dart b/test/native_glass_surface_test.dart new file mode 100644 index 000000000..fea5e0f65 --- /dev/null +++ b/test/native_glass_surface_test.dart @@ -0,0 +1,188 @@ +// +// native_glass_surface_test.dart +// +// The native glass surface hosts the real Liquid Glass as a background only: +// the app-owned tab row stays in Flutter, above the platform view, and keeps +// every gesture. The channel contract mirrors the Swift side +// (ios/Runner/NativeGlassSurface.swift): creation params configure the glass, +// theme changes re-send them as 'update', and no item data ever crosses. +// + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mithka/app/native_glass_surface.dart'; +import 'package:mithka/theme/app_theme.dart'; + +void main() { + testWidgets('glass surface stays behind the app row and follows the theme', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final messenger = tester.binding.defaultBinaryMessenger; + final createdParams = >[]; + final updates = []; + var id = -1; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final arguments = call.arguments as Map; + id = arguments['id'] as int; + createdParams.add( + const StandardMessageCodec().decodeMessage( + ByteData.sublistView(arguments['params'] as Uint8List), + ) + as Map, + ); + messenger.setMockMethodCallHandler( + MethodChannel('mithka/native_glass/$id'), + (call) async { + updates.add(call); + return null; + }, + ); + } + return null; + }); + try { + const tabKey = ValueKey('bottom-tab-0'); + var taps = 0; + final themeMode = ValueNotifier(ThemeMode.light); + addTearDown(themeMode.dispose); + + Widget appRow(AppColors colors) => SizedBox( + height: 62, + child: Row( + children: [ + Expanded( + child: GestureDetector( + key: tabKey, + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: ColoredBox( + color: colors.linkBlue.withValues(alpha: 0.08), + child: const SizedBox.expand(), + ), + ), + ), + const Expanded(child: SizedBox.expand()), + ], + ), + ); + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, _) => MaterialApp( + themeMode: mode, + theme: ThemeData( + brightness: Brightness.light, + extensions: [AppColors.light], + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + extensions: [AppColors.dark], + ), + home: Builder( + builder: (context) => Align( + alignment: Alignment.bottomCenter, + child: NativeGlassSurface(child: appRow(context.colors)), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(id, greaterThanOrEqualTo(0)); + expect(createdParams, hasLength(1)); + final initial = createdParams.single; + expect(initial['dark'], isFalse); + expect(initial['radius'], AppRadius.pill); + expect(initial['rtl'], isFalse); + expect((initial['tint'] as int).toSigned(32), nativeGlassTint(false)); + + // The glass never receives item data: no labels, no icons, no selection. + for (final params in createdParams) { + expect(params.containsKey('items'), isFalse); + expect(params.containsKey('selection'), isFalse); + } + + // The app row paints above the platform view and keeps its taps. + final surface = find.byKey(const ValueKey('native-glass-surface')); + expect(surface, findsOneWidget); + expect( + tester.getRect(find.byType(UiKitView)).bottom, + tester.getRect(surface).bottom, + ); + await tester.tap(find.byKey(tabKey), warnIfMissed: false); + await tester.pump(); + expect(taps, 1); + + // An explicitly light app theme while the OS reports dark must keep the + // surface light: `dark` derives from the effective app palette only. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + addTearDown( + () => tester.platformDispatcher.clearPlatformBrightnessTestValue(), + ); + themeMode.value = ThemeMode.light; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byKey(const ValueKey('native-glass-surface')), + findsOneWidget, + ); + expect(createdParams, hasLength(1)); + final lightUpdate = + updates.lastWhere((call) => call.method == 'update').arguments as Map; + expect(lightUpdate['dark'], isFalse); + + // Flipping the live theme re-sends the configuration through the + // existing platform view's channel via didChangeDependencies without + // recreating the UiKitView. + themeMode.value = ThemeMode.dark; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byKey(const ValueKey('native-glass-surface')), + findsOneWidget, + ); + expect(createdParams, hasLength(1)); + expect(updates.map((call) => call.method), contains('update')); + final update = + updates.lastWhere((call) => call.method == 'update').arguments as Map; + expect(update['dark'], isTrue); + expect((update['tint'] as int).toSigned(32), nativeGlassTint(true)); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(tester.takeException(), isNull); + } finally { + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + if (id >= 0) { + messenger.setMockMethodCallHandler( + MethodChannel('mithka/native_glass/$id'), + null, + ); + } + debugDefaultTargetPlatformOverride = null; + } + }); + + test('the tint follows Telegram iOS: white at 0.10 light / 0.025 dark', () { + final light = nativeGlassTint(false).toUnsigned(32); + final dark = nativeGlassTint(true).toUnsigned(32); + // Plain white RGB at Telegram's alphas — never the bar's surface colour, + // which in the light palette is the same white as the page behind it and + // composites to nothing. + expect(light, 0x1AFFFFFF); + expect(dark, 0x06FFFFFF); + // The tint is a whisper on purpose: strictly inside (0, 1) and lighter in + // dark mode, because any alpha that would make a surface-coloured veil + // visible turns the tint into a fill that hides the material. + expect((light >> 24) / 255, lessThan(0.2)); + expect((dark >> 24), lessThan(light >> 24)); + }); +} From cfed7d630e4a0952fbabba8f51d5da98e8b69588 Mon Sep 17 00:00:00 2001 From: TiaraBasori Date: Mon, 26 Oct 2026 06:20:57 +0800 Subject: [PATCH 2/2] fix(glass-bar): make the slot mask concentric with the capsule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pressed-tab mask, ripple, and the selection pill were 9px rounded rectangles inside a stadium capsule — two different shapes at the same place, visibly mismatched at both ends. The 9px radius came from #193, where a stadium indicator clipped the label row's lower corners; the glass row has been icon-only since #261, so nothing clips anymore. The row sits a 5px inset inside the capsule's stadium, so a stadium of the row's own height has its end arcs on the same centres as the capsule's — the shapes are concentric and their edges parallel everywhere. The icon block clears the arc even in the narrowest tablet slot (365px squared against the 31px arc disc). The coverage test now measures containment through the clamped RRect (the real stadium geometry) with the bar's real fixed row height, and the capsule probes pick the outermost stadium now that slot ripples share the pill radius. --- lib/app/liquid_glass_bottom_bar.dart | 7 +- lib/app/main_tab_view.dart | 9 +- test/glass_bottom_bar_geometry_test.dart | 32 +++++-- test/liquid_glass_bottom_bar_test.dart | 114 +++++++---------------- test/native_glass_bar_geometry_test.dart | 31 ++++-- 5 files changed, 91 insertions(+), 102 deletions(-) diff --git a/lib/app/liquid_glass_bottom_bar.dart b/lib/app/liquid_glass_bottom_bar.dart index 3845c2eaf..31e123de6 100644 --- a/lib/app/liquid_glass_bottom_bar.dart +++ b/lib/app/liquid_glass_bottom_bar.dart @@ -39,7 +39,12 @@ class GlassSelectionIndicator extends StatelessWidget { child: DecoratedBox( key: const ValueKey('liquid-glass-selection'), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.control), + // A stadium concentric with the capsule: the row sits a + // 5px inset inside the capsule's stadium, so this stadium's + // end arcs share the capsule's centres and the two edges + // stay parallel everywhere. A small rectangle radius would + // read as a different shape from the capsule around it. + borderRadius: BorderRadius.circular(AppRadius.pill), color: colors.linkBlue.withValues( alpha: highContrast ? 0.22 : (dark ? 0.19 : 0.10), ), diff --git a/lib/app/main_tab_view.dart b/lib/app/main_tab_view.dart index a9102b084..675c509d6 100644 --- a/lib/app/main_tab_view.dart +++ b/lib/app/main_tab_view.dart @@ -2362,11 +2362,12 @@ class _MainBottomBar extends StatelessWidget { child: AppInteractiveSurface( key: ValueKey('bottom-tab-${items[i].index}'), // Match the selection indicator's corner radius so the tap - // ripple, hover tint, and highlight share one slot shape. A - // full stadium would clip the label's corners (see - // LiquidGlassBottomBar). + // ripple, hover tint, and highlight share one slot shape: + // a stadium concentric with the capsule. The glass row is + // icon-only (no label line to clip at the arc), and the icon + // block clears the arc even in the narrowest tablet slot. borderRadius: glass - ? BorderRadius.circular(AppRadius.control) + ? BorderRadius.circular(AppRadius.pill) : BorderRadius.zero, semanticLabel: items[i].label.l10n(context), selected: selection == i, diff --git a/test/glass_bottom_bar_geometry_test.dart b/test/glass_bottom_bar_geometry_test.dart index 5231916fd..b13584645 100644 --- a/test/glass_bottom_bar_geometry_test.dart +++ b/test/glass_bottom_bar_geometry_test.dart @@ -437,16 +437,30 @@ String? semanticLabelOf(WidgetTester tester, Key key) => tester /// The stadium the glass and the row are clipped into: the bar's pill-radius /// ClipRRect, which is the visible capsule rect in every host. Rect _capsuleRect(WidgetTester tester) { - final finder = find.descendant( - of: find.byKey(_barKey), - matching: find.byWidgetPredicate( - (widget) => - widget is ClipRRect && - widget.borderRadius == BorderRadius.circular(AppRadius.pill), - ), + // The capsule's stadium ClipRRect plus the slots' ripple clips share the + // pill radius; the capsule is the outermost of them — the only one that + // contains every other. + final stadiums = find + .descendant( + of: find.byKey(_barKey), + matching: find.byWidgetPredicate( + (widget) => + widget is ClipRRect && + widget.borderRadius == BorderRadius.circular(AppRadius.pill), + ), + ) + .evaluate(); + expect(stadiums, isNotEmpty); + final rects = stadiums + .map( + (element) => tester.getRect( + find.byElementPredicate((it) => it == element), + ), + ) + .toList(); + return rects.reduce( + (a, b) => a.contains(b.topLeft) && a.contains(b.bottomRight) ? a : b, ); - expect(finder, findsOneWidget); - return tester.getRect(finder); } /// Runs [body] with a non-iOS target platform, clearing the override before diff --git a/test/liquid_glass_bottom_bar_test.dart b/test/liquid_glass_bottom_bar_test.dart index 2442a2383..87c6e60fe 100644 --- a/test/liquid_glass_bottom_bar_test.dart +++ b/test/liquid_glass_bottom_bar_test.dart @@ -32,39 +32,19 @@ void main() { child: LiquidGlassBottomBar( selection: selected, itemCount: labels.length, + // The glass row is icon-only and never grows for text + // scale; the fixture keeps the row's real 62px height. child: SizedBox( - height: 62 + (11 * scale - 11) * 1.1, + height: 62, child: Row( children: [ for (final label in labels) Expanded( child: Center( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 2, - ), - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - SizedBox( - key: ValueKey('scaled-icon-$label'), - width: 36, - height: 28, - ), - const SizedBox(height: 2), - Text( - label, - key: ValueKey('scaled-label-$label'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - height: 1.1, - ), - ), - ], - ), + child: SizedBox( + key: ValueKey('scaled-icon-$label'), + width: 36, + height: 28, ), ), ), @@ -84,7 +64,7 @@ void main() { tester.widget(indicator).decoration as BoxDecoration; final radius = decoration.borderRadius! as BorderRadius; final shape = radius.toRRect(tester.getRect(indicator)); - for (final prefix in ['scaled-icon', 'scaled-label']) { + for (final prefix in ['scaled-icon']) { final bounds = tester.getRect( find.byKey(ValueKey('$prefix-${labels[selected]}')), ); @@ -202,33 +182,16 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('selection indicator fully covers the selected icon and label', ( + testWidgets('selection indicator fully covers the selected icon', ( tester, ) async { - // A full stadium (radius = slot height) pulls the corner arcs ~10 px - // inward at the label row, so the label's lower corners fell outside the - // filled pill — most visibly on narrow tablet tabs. The indicator must - // keep a small corner radius so the whole icon+label of the selected tab - // sits inside the filled shape at any slot size or text scale. - // - // The child mirrors the real bottom bar: each slot is an Expanded cell - // centring an icon block above a label with a 2 px gutter on each side. + // The glass row is icon-only: each slot is an Expanded cell centring an + // icon block. The indicator is a stadium concentric with the capsule + // (see GlassSelectionIndicator), so the whole icon of the selected tab + // sits inside the filled shape even in the narrowest tablet slot — the + // case #193 originally covered for the icon+label column. Widget slot(String label) => Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(key: ValueKey('icon-$label'), width: 36, height: 28), - const SizedBox(height: 2), - Text( - label, - key: ValueKey('label-$label'), - style: const TextStyle(fontSize: 11), - ), - ], - ), - ), + child: SizedBox(key: ValueKey('icon-$label'), width: 36, height: 28), ); await tester.pumpWidget( MaterialApp( @@ -242,13 +205,18 @@ void main() { child: LiquidGlassBottomBar( selection: 0, itemCount: 4, - child: Row( - children: [ - Expanded(child: slot('Chats')), - Expanded(child: slot('Channels')), - Expanded(child: slot('Contacts')), - Expanded(child: slot('Moments')), - ], + // The real bar's row: icon-only, fixed 62px height, so the + // stadium's clamp is the row's half height like on device. + child: SizedBox( + height: 62, + child: Row( + children: [ + Expanded(child: slot('Chats')), + Expanded(child: slot('Channels')), + Expanded(child: slot('Contacts')), + Expanded(child: slot('Moments')), + ], + ), ), ), ), @@ -262,28 +230,16 @@ void main() { (tester.widget(pill).decoration as BoxDecoration) .borderRadius as BorderRadius; - // Guard against a regression to a full stadium, which is what clipped. - expect(radius.bottomLeft.y, lessThanOrEqualTo(16)); - expect(radius.bottomLeft.y, isNot(AppRadius.pill)); + // The indicator is a stadium: the radius clamps to the slot's half + // height, matching the capsule's concentric end arcs. + expect(radius.bottomLeft.y, AppRadius.pill); + // toRRect applies the stadium clamp (999 → half of width/height), which + // the manual point test below must mirror. + final shape = radius.toRRect(pr); - bool insideRounded(Offset p) { - if (!pr.contains(p)) return false; - final r = radius.bottomLeft.y; - // Only the corner discs can carve out the bounding box. - final dx = p.dx < pr.left + r - ? p.dx - (pr.left + r) - : p.dx > pr.right - r - ? p.dx - (pr.right - r) - : 0.0; - final dy = p.dy < pr.top + r - ? p.dy - (pr.top + r) - : p.dy > pr.bottom - r - ? p.dy - (pr.bottom - r) - : 0.0; - return dx == 0 || dy == 0 || dx * dx + dy * dy <= r * r; - } + bool insideRounded(Offset p) => shape.contains(p); - for (final key in ['icon-Chats', 'label-Chats']) { + for (final key in ['icon-Chats']) { final r = tester.getRect(find.byKey(ValueKey(key))); for (final corner in [ r.topLeft, diff --git a/test/native_glass_bar_geometry_test.dart b/test/native_glass_bar_geometry_test.dart index 42d6eac00..8da2e4655 100644 --- a/test/native_glass_bar_geometry_test.dart +++ b/test/native_glass_bar_geometry_test.dart @@ -188,16 +188,29 @@ void main() { final bar = find.byType(LiquidGlassBottomBar); expect(bar, findsOneWidget); expect(find.byKey(_surfaceKey), findsNothing); - final stadium = find.descendant( - of: bar, - matching: find.byWidgetPredicate( - (widget) => - widget is ClipRRect && - widget.borderRadius == BorderRadius.circular(AppRadius.pill), - ), + final stadium = find + .descendant( + of: bar, + matching: find.byWidgetPredicate( + (widget) => + widget is ClipRRect && + widget.borderRadius == BorderRadius.circular(AppRadius.pill), + ), + ) + .evaluate(); + // The capsule's stadium plus the slots' ripple clips share the pill + // radius; the capsule is the outermost of them. + expect(stadium, isNotEmpty); + final stadiumRects = stadium + .map( + (element) => + tester.getRect(find.byElementPredicate((it) => it == element)), + ) + .toList(); + final capsuleRect = stadiumRects.reduce( + (a, b) => a.contains(b.topLeft) && a.contains(b.bottomRight) ? a : b, ); - expect(stadium, findsOneWidget); - expect(tester.getRect(stadium), nativeSurface); + expect(capsuleRect, nativeSurface); expect(tester.getRect(find.byKey(_firstSlotKey)), nativeSlot); expect(tester.getRect(find.byKey(_indicatorKey)), nativeSlot); expect(tester.takeException(), isNull);