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/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 114e349af..675c509d6 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({ @@ -2314,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, @@ -2429,11 +2478,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..b13584645 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, ); @@ -426,22 +437,36 @@ 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 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/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 new file mode 100644 index 000000000..8da2e4655 --- /dev/null +++ b/test/native_glass_bar_geometry_test.dart @@ -0,0 +1,440 @@ +// +// 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), + ), + ) + .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(capsuleRect, 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)); + }); +}