From 4c504a658c581b5e8c0d4d16d3824e8acaa7bbdd Mon Sep 17 00:00:00 2001 From: TiaraBasori Date: Sat, 17 Oct 2026 02:20:00 +0800 Subject: [PATCH] Host a public-API native glass surface behind the app tab bar The system tab bar was a window-bottom UITabBar: its selection capsule painted against its own slot metrics, so a tablet split shell's narrow sidebar column misplaced the highlight and had to opt out of the native glass (useNativeGlass:false, PR #210). Replace whole-bar hosting with a glass-surface-only platform view using only documented public UIKit APIs: - NativeGlassSurface.swift hosts UIVisualEffectView(UIGlassEffect(.regular)) on iOS 26+ (system material UIBlurEffect below), styled via public overrideUserInterfaceStyle, tintColor, isInteractive = false, and layer.cornerRadius. GlassSurfaceHost overrides init(frame:); no private UIKit classes, selectors, or Objective-C runtime hooks are used. The view draws no tabs and takes no touches. - Flutter keeps the tab row, badges, and gestures, and adds the extracted GlassSelectionIndicator (from LiquidGlassBottomBar) so selection renders identically over either glass. One path now serves the phone bottom bar and the sidebar column; the split-shell opt-out and its test are gone because no system-drawn element is positioned relative to tab slots. - NativeBottomTabBar (UITabBar, Heroicons rasterizer, context menu, badge channel) is deleted with its test; the native surface test pins the new channel contract and the geometry test pins pill-over-slot in both shells. --- ios/Runner.xcodeproj/project.pbxproj | 8 +- ios/Runner/AppDelegate.swift | 4 +- ios/Runner/NativeBottomTabBar.swift | 193 -------- ios/Runner/NativeGlassSurface.swift | 115 +++++ lib/app/liquid_glass_bottom_bar.dart | 110 +++-- lib/app/main_tab_view.dart | 74 +-- lib/app/native_bottom_tab_bar.dart | 128 ------ lib/app/native_glass_surface.dart | 87 ++++ test/native_bottom_tab_bar_test.dart | 132 ------ test/native_glass_bar_geometry_test.dart | 427 ++++++++++++++++++ test/native_glass_surface_test.dart | 182 ++++++++ ...tablet_glass_bar_native_fallback_test.dart | 202 --------- 12 files changed, 916 insertions(+), 746 deletions(-) delete mode 100644 ios/Runner/NativeBottomTabBar.swift create mode 100644 ios/Runner/NativeGlassSurface.swift delete mode 100644 lib/app/native_bottom_tab_bar.dart create mode 100644 lib/app/native_glass_surface.dart delete mode 100644 test/native_bottom_tab_bar_test.dart create mode 100644 test/native_glass_bar_geometry_test.dart create mode 100644 test/native_glass_surface_test.dart delete mode 100644 test/tablet_glass_bar_native_fallback_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d2bcf8a8..8cd071e5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -37,7 +37,7 @@ 9A4F20EC31E1000100000001 /* ApplePCCBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */; }; A17D0FF131E1000100000001 /* HandoffBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17D0FF031E1000100000001 /* HandoffBridge.swift */; }; A685F7A8DE8A8339A7CFE48A /* RunnerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8FADD48E0FE94429FDE1912 /* RunnerUITests.swift */; }; - AC39B11031E1000100000001 /* NativeBottomTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC39B11131E1000100000001 /* NativeBottomTabBar.swift */; }; + AC39B11231E1000100000001 /* NativeGlassSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC39B11331E1000100000001 /* NativeGlassSurface.swift */; }; B005C7BA5D2714CB149B4B95 /* MithkaBlue.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0CC7151EAA772074B2F1F159 /* MithkaBlue.icon */; }; B45E6836D8D2D05AB2676E08 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5790DB6BBC8220D67CE86B /* NotificationService.swift */; }; BD06D3B4F53874C98C6D9618 /* tdjson.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7AF24ACBEB1466595B72E2DD /* tdjson.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -136,7 +136,7 @@ 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 = ""; }; A257B729519AB240FBD5E29F /* MithkaPurple.icon */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.iconcomposer.icon; path = MithkaPurple.icon; sourceTree = ""; }; - AC39B11131E1000100000001 /* NativeBottomTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeBottomTabBar.swift; sourceTree = ""; }; + AC39B11331E1000100000001 /* NativeGlassSurface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeGlassSurface.swift; 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 = ""; }; D20D61E8E4A929586A940D51 /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; @@ -273,7 +273,7 @@ 9A4F20F131E1000100000001 /* NowPlayingBridge.swift */, 9A4F20E931E1000100000001 /* CommunicationNotificationBridge.swift */, 9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */, - AC39B11131E1000100000001 /* NativeBottomTabBar.swift */, + AC39B11331E1000100000001 /* NativeGlassSurface.swift */, A17D0FF031E1000100000001 /* HandoffBridge.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -700,7 +700,7 @@ 9A4F20F231E1000100000001 /* NowPlayingBridge.swift in Sources */, 9A4F20EA31E1000100000001 /* CommunicationNotificationBridge.swift in Sources */, 9A4F20EC31E1000100000001 /* ApplePCCBridge.swift in Sources */, - AC39B11031E1000100000001 /* NativeBottomTabBar.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 6230f650..f5da5fd3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -172,8 +172,8 @@ import UserNotifications guard !didRegisterFlutterPlugins else { return } didRegisterFlutterPlugins = true GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "NativeBottomTabBar") { - registrar.register(NativeBottomTabBarFactory(registrar: registrar), withId: "mithka/native_bottom_bar") + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "NativeGlassSurface") { + registrar.register(NativeGlassSurfaceFactory(registrar: registrar), withId: "mithka/native_glass") } let windowGeometryChannel = FlutterMethodChannel( name: "mithka/window_geometry", diff --git a/ios/Runner/NativeBottomTabBar.swift b/ios/Runner/NativeBottomTabBar.swift deleted file mode 100644 index d1a8bd2e..00000000 --- a/ios/Runner/NativeBottomTabBar.swift +++ /dev/null @@ -1,193 +0,0 @@ -import CoreText -import Flutter -import UIKit - -@MainActor -final class NativeBottomTabBarFactory: 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 { - NativeBottomTabBar(frame: frame, id: viewId, registrar: registrar, arguments: args) - } -} - -@MainActor -private final class NativeBottomTabBar: NSObject, FlutterPlatformView { - private let host: NativeTabBarHost - private let channel: FlutterMethodChannel - - init(frame: CGRect, id: Int64, registrar: FlutterPluginRegistrar, arguments: Any?) { - host = NativeTabBarHost(frame: frame, registrar: registrar) - channel = FlutterMethodChannel( - name: "mithka/native_bottom_bar/\(id)", binaryMessenger: registrar.messenger() - ) - super.init() - host.onSelect = { [weak self] id in self?.channel.invokeMethod("select", arguments: id) } - host.onClearUnread = { [weak self] in self?.channel.invokeMethod("clearUnread", arguments: nil) } - host.onHeight = { [weak self] height in self?.channel.invokeMethod("height", arguments: height) } - if let configuration = arguments as? [String: Any] { host.update(configuration) } - channel.setMethodCallHandler { [weak self] call, result in - guard call.method == "update", let configuration = call.arguments as? [String: Any] else { - result(FlutterMethodNotImplemented) - return - } - self?.host.update(configuration) - result(nil) - } - } - - func view() -> UIView { host } - - deinit { channel.setMethodCallHandler(nil) } -} - -/// The native bar remains unstyled: UIKit supplies Liquid Glass on iOS 26+, -/// and the system's translucent tab appearance on earlier releases. -@MainActor -private final class NativeTabBarHost: UIView, UITabBarDelegate, UIContextMenuInteractionDelegate { - private let bar = SafeInsetTabBar() - private let registrar: FlutterPluginRegistrar - private var descriptors: NSArray = [] - private var lastHeight: CGFloat = 0 - private var unread = 0 - private var clearUnreadLabel = "" - var onSelect: ((Int) -> Void)? - var onClearUnread: (() -> Void)? - var onHeight: ((CGFloat) -> Void)? - - init(frame: CGRect, registrar: FlutterPluginRegistrar) { - self.registrar = registrar - super.init(frame: frame) - backgroundColor = .clear - isOpaque = false - bar.delegate = self - addSubview(bar) - bar.addInteraction(UIContextMenuInteraction(delegate: self)) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func update(_ configuration: [String: Any]) { - overrideUserInterfaceStyle = configuration["dark"] as? Bool == true ? .dark : .light - bar.semanticContentAttribute = configuration["rtl"] as? Bool == true - ? .forceRightToLeft : .forceLeftToRight - bar.bottomInset = max(0, (configuration["safeBottom"] as? NSNumber)?.doubleValue ?? 0) - if let argb = configuration["tint"] as? NSNumber { - let value = argb.uint32Value - bar.tintColor = UIColor( - red: CGFloat((value >> 16) & 255) / 255, - green: CGFloat((value >> 8) & 255) / 255, - blue: CGFloat(value & 255) / 255, - alpha: CGFloat((value >> 24) & 255) / 255 - ) - } - let items = configuration["items"] as? [[String: Any]] ?? [] - if !descriptors.isEqual(to: items) { - descriptors = items as NSArray - bar.setItems(items.compactMap { item in - guard let id = item["id"] as? Int, let label = item["label"] as? String else { return nil } - return UITabBarItem(title: label, image: icon(item), tag: id) - }, animated: false) - } - let selection = configuration["selection"] as? Int ?? 0 - bar.selectedItem = bar.items?.first { $0.tag == selection } - unread = configuration["unread"] as? Int ?? 0 - clearUnreadLabel = configuration["clearUnreadLabel"] as? String ?? "" - if let messages = bar.items?.first(where: { $0.tag == 0 }) { - messages.badgeValue = unread > 0 ? (configuration["unreadLabel"] as? String ?? String(unread)) : nil - messages.accessibilityCustomActions = unread > 0 ? [ - UIAccessibilityCustomAction(name: clearUnreadLabel) { [weak self] _ in - self?.onClearUnread?() - return true - } - ] : nil - } - setNeedsLayout() - } - - override func layoutSubviews() { - super.layoutSubviews() - let height = bar.sizeThatFits(CGSize(width: bounds.width, height: 200)).height - bar.frame = CGRect(x: 0, y: bounds.height - height, width: bounds.width, height: height) - if height > 0 && height != lastHeight { - lastHeight = height - // Flutter updates its layout on the next turn, outside UIKit layout. - DispatchQueue.main.async { [weak self] in self?.onHeight?(height) } - } - } - - func position(for bar: UIBarPositioning) -> UIBarPosition { .bottom } - - func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - onSelect?(item.tag) - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - guard unread > 0, let items = bar.items, !items.isEmpty else { return nil } - let width = bar.bounds.width / CGFloat(items.count) - let index = min(items.count - 1, max(0, Int(location.x / max(1, width)))) - let logicalIndex = bar.effectiveUserInterfaceLayoutDirection == .rightToLeft - ? items.count - 1 - index : index - guard items[logicalIndex].tag == 0 else { return nil } - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in - guard let self else { return nil } - return UIMenu(children: [UIAction(title: self.clearUnreadLabel) { [weak self] _ in - self?.onClearUnread?() - }]) - } - } - - /// Render the same bundled Heroicons glyphs used by AppIcon, without SF - /// Symbols, substitute icons, or a separate asset set that can drift. - private func icon(_ descriptor: [String: Any]) -> UIImage? { - let fonts = [ - "Heroicons Solid": "solid", "Heroicons Outline": "outline", - "Heroicons Mini": "mini", "Heroicons Micro": "micro", - ] - guard - let family = descriptor["fontFamily"] as? String, - let file = fonts[family], - let codePoint = descriptor["codePoint"] as? Int, - let scalar = UnicodeScalar(codePoint) - else { return nil } - let key = registrar.lookupKey(forAsset: "assets/fonts/heroicons-\(file).ttf", fromPackage: "heroicons_flutter") - guard - let path = Bundle.main.path(forResource: key, ofType: nil), - let provider = CGDataProvider(url: URL(fileURLWithPath: path) as CFURL), - let font = CGFont(provider), - let name = font.postScriptName - else { return nil } - CTFontManagerRegisterGraphicsFont(font, nil) - guard let uiFont = UIFont(name: name as String, size: 24) else { return nil } - return UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in - (String(scalar) as NSString).draw( - in: CGRect(x: 0, y: 0, width: 24, height: 24), - withAttributes: [.font: uiFont, .foregroundColor: UIColor.black] - ) - }.withRenderingMode(.alwaysTemplate) - } -} - -@MainActor -private final class SafeInsetTabBar: UITabBar { - var bottomInset: CGFloat = 0 - override var safeAreaInsets: UIEdgeInsets { - UIEdgeInsets(top: 0, left: super.safeAreaInsets.left, bottom: bottomInset, right: super.safeAreaInsets.right) - } -} diff --git a/ios/Runner/NativeGlassSurface.swift b/ios/Runner/NativeGlassSurface.swift new file mode 100644 index 00000000..de7bdd06 --- /dev/null +++ b/ios/Runner/NativeGlassSurface.swift @@ -0,0 +1,115 @@ +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 + ) + } + if let current = effectView.effect as? UIGlassEffect { + if current.tintColor != tint { current.tintColor = tint } + } 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 5d90e48a..5d1be8c6 100644 --- a/lib/app/liquid_glass_bottom_bar.dart +++ b/lib/app/liquid_glass_bottom_bar.dart @@ -5,6 +5,62 @@ import 'package:flutter/widgets.dart'; import '../theme/app_motion.dart'; import '../theme/app_theme.dart'; +const _indicatorWhite = Color(0xFFFFFFFF); + +/// The animated selection pill shared by the app glass bar (non-iOS fallback) +/// and the native glass surface (iOS): a rounded, tinted highlight that slides +/// to the selected slot. Both hosts draw the same indicator, so selection +/// looks identical whichever glass is beneath it. +class GlassSelectionIndicator extends StatelessWidget { + const GlassSelectionIndicator({ + super.key, + required this.selection, + required this.itemCount, + }) : assert(itemCount > 0), + assert(selection >= 0 && selection < itemCount); + + final int selection; + final int itemCount; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final dark = colors.navBar.computeLuminance() < 0.5; + final highContrast = MediaQuery.highContrastOf(context); + return LayoutBuilder( + builder: (context, constraints) => Stack( + children: [ + AnimatedPositionedDirectional( + duration: AppMotion.duration(context, AppMotion.deliberate), + curve: AppMotion.emphasized, + start: constraints.maxWidth * selection / itemCount, + width: constraints.maxWidth / itemCount, + top: 0, + bottom: 0, + child: IgnorePointer( + child: DecoratedBox( + key: const ValueKey('liquid-glass-selection'), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.control), + color: colors.linkBlue.withValues( + alpha: highContrast ? 0.22 : (dark ? 0.19 : 0.10), + ), + border: Border.all( + color: highContrast + ? colors.linkBlue + : _indicatorWhite.withValues(alpha: dark ? 0.16 : 0.70), + width: 0.8, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + /// Glass fallback for non-iOS hosts. The shell overlays this transparent /// surface above the scrollable content, with no opaque backing strip. class LiquidGlassBottomBar extends StatelessWidget { @@ -25,7 +81,7 @@ class LiquidGlassBottomBar extends StatelessWidget { final colors = context.colors; final dark = colors.navBar.computeLuminance() < 0.5; final highContrast = MediaQuery.highContrastOf(context); - const white = Color(0xFFFFFFFF); + const white = _indicatorWhite; const black = Color(0xFF000000); const radius = BorderRadius.all(Radius.circular(AppRadius.pill)); final rim = white.withValues(alpha: dark ? 0.22 : 0.85); @@ -84,55 +140,9 @@ class LiquidGlassBottomBar extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) => Stack( - children: [ - AnimatedPositionedDirectional( - duration: AppMotion.duration( - context, - AppMotion.deliberate, - ), - curve: AppMotion.emphasized, - start: - constraints.maxWidth * - selection / - itemCount, - width: constraints.maxWidth / itemCount, - top: 0, - bottom: 0, - child: IgnorePointer( - child: DecoratedBox( - key: const ValueKey( - 'liquid-glass-selection', - ), - // A stadium (radius = slot height) pulls its - // corner arcs ~10 px inward at the label row, - // cutting the label/icon of narrow tablet - // tabs. Capping the radius keeps every tab's - // icon+label inside the filled shape. - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppRadius.control, - ), - color: colors.linkBlue.withValues( - alpha: highContrast - ? 0.22 - : (dark ? 0.19 : 0.10), - ), - border: Border.all( - color: highContrast - ? colors.linkBlue - : white.withValues( - alpha: dark ? 0.16 : 0.70, - ), - width: 0.8, - ), - ), - ), - ), - ), - ], - ), + child: GlassSelectionIndicator( + selection: selection, + itemCount: itemCount, ), ), child, diff --git a/lib/app/main_tab_view.dart b/lib/app/main_tab_view.dart index 9244c64b..4b4c8c14 100644 --- a/lib/app/main_tab_view.dart +++ b/lib/app/main_tab_view.dart @@ -61,8 +61,9 @@ import 'desktop_navigation_rail.dart'; import 'desktop_utility_window.dart'; import 'detail_content_reveal.dart'; import 'horizontal_safe_viewport.dart'; -import 'liquid_glass_bottom_bar.dart'; -import 'native_bottom_tab_bar.dart'; +import 'liquid_glass_bottom_bar.dart' + show GlassSelectionIndicator, LiquidGlassBottomBar; +import 'native_glass_surface.dart'; import 'primary_chat_launcher.dart'; import 'unread_badge_model.dart'; @@ -1271,11 +1272,6 @@ abstract class _MainRootViewState extends State { unread: _unread.countFor( theme.unreadBadgeMode, ), - // The system glass bar measures itself - // against the window bottom, not this - // sidebar column; draw the app glass - // whose indicator follows the tabs. - useNativeGlass: false, ), ), ), @@ -2164,7 +2160,6 @@ class _MainBottomBar extends StatelessWidget { required this.items, required this.unread, this.chatListController, - this.useNativeGlass = true, }); final int selection; final ValueChanged onSelect; @@ -2173,13 +2168,6 @@ class _MainBottomBar extends StatelessWidget { final int unread; final ChatListController? chatListController; - /// The system glass bar is a window-bottom bar: UIKit paints its selection - /// capsule to its own slot metrics, so a split shell hosting it inside a - /// narrow sidebar column misplaces the highlight over the tabs. Split-shell - /// callers opt out to draw the app-owned glass bar instead, whose indicator - /// follows the actual tab slots. - final bool useNativeGlass; - /// Label size the bar is laid out around. The icon block above it keeps its /// size at every text scale, so only this line's growth is added to the bar. static const double _labelSize = 11; @@ -2275,26 +2263,6 @@ class _MainBottomBar extends StatelessWidget { ), ); } - if (glass && - useNativeGlass && - !kIsWeb && - defaultTargetPlatform == TargetPlatform.iOS) { - return NativeBottomTabBar( - items: [ - for (final item in items) - NativeBottomTabItem( - id: item.index, - label: item.label.l10n(context), - icon: item.icon, - ), - ], - selection: selection, - unread: unread, - unreadLabel: theme.unreadBadgeOverflowMode.format(unread), - onSelect: onSelect, - onClearUnread: onClearUnread, - ); - } final labelGrowth = math.max( 0.0, (MediaQuery.textScalerOf(context).scale(_labelSize) - _labelSize) * @@ -2406,6 +2374,42 @@ class _MainBottomBar extends StatelessWidget { ), ); if (glass) { + // iOS hosts the real 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 Flutter glass bar remains the + // fallback for every other target. + 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( + top: false, + minimum: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 0), + child: NativeGlassSurface( + child: Padding( + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + Positioned.fill( + child: GlassSelectionIndicator( + selection: selection, + itemCount: items.length, + ), + ), + controls, + ], + ), + ), + ), + ), + ); + } return LiquidGlassBottomBar( selection: selection, itemCount: items.length, diff --git a/lib/app/native_bottom_tab_bar.dart b/lib/app/native_bottom_tab_bar.dart deleted file mode 100644 index bdd975db..00000000 --- a/lib/app/native_bottom_tab_bar.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -import '../components/app_icons.dart'; -import '../l10n/app_localizations.dart'; -import '../theme/app_theme.dart'; - -class NativeBottomTabItem { - const NativeBottomTabItem({ - required this.id, - required this.label, - required this.icon, - }); - final int id; - final String label; - final AppIconData icon; - - Map toMap() => { - 'id': id, - 'label': label, - 'codePoint': icon.data.codePoint, - 'fontFamily': icon.data.fontFamily!, - }; -} - -/// UIKit owns the glass, tab layout, selection interaction, and VoiceOver. -/// Only app-owned Heroicons and localized titles cross the platform channel. -class NativeBottomTabBar extends StatefulWidget { - const NativeBottomTabBar({ - super.key, - required this.items, - required this.selection, - required this.unread, - this.unreadLabel, - required this.onSelect, - required this.onClearUnread, - }); - - final List items; - final int selection; - final int unread; - final String? unreadLabel; - final ValueChanged onSelect; - final VoidCallback onClearUnread; - - @override - State createState() => _NativeBottomTabBarState(); -} - -class _NativeBottomTabBarState extends State { - MethodChannel? _channel; - double? _height; - - Map _configuration() => { - 'items': widget.items.map((item) => item.toMap()).toList(), - 'selection': widget.items[widget.selection].id, - 'unread': widget.unread, - 'unreadLabel': widget.unreadLabel ?? '${widget.unread}', - 'clearUnreadLabel': AppStringKeys.channelDirectMessagesMarkRead.l10n( - context, - ), - 'tint': context.colors.linkBlue.toARGB32(), - 'dark': context.colors.background.computeLuminance() < 0.5, - 'rtl': Directionality.of(context) == TextDirection.rtl, - 'safeBottom': MediaQuery.paddingOf(context).bottom, - }; - - @override - void didUpdateWidget(NativeBottomTabBar oldWidget) { - super.didUpdateWidget(oldWidget); - _update(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _update(); - } - - void _update() { - final channel = _channel; - if (channel != null) { - unawaited(channel.invokeMethod('update', _configuration())); - } - } - - void _created(int id) { - _channel = MethodChannel('mithka/native_bottom_bar/$id') - ..setMethodCallHandler((call) async { - if (!mounted) return; - switch (call.method) { - case 'select': - final index = widget.items.indexWhere( - (item) => item.id == call.arguments, - ); - if (index >= 0) widget.onSelect(index); - case 'clearUnread': - widget.onClearUnread(); - case 'height': - final height = (call.arguments as num).toDouble(); - if (height.isFinite && height > 0 && height != _height) { - setState(() => _height = height); - } - } - }); - _update(); - } - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - super.dispose(); - } - - @override - Widget build(BuildContext context) => SizedBox( - key: const ValueKey('native-bottom-tab-bar'), - height: _height ?? 64 + MediaQuery.paddingOf(context).bottom, - child: UiKitView( - viewType: 'mithka/native_bottom_bar', - creationParamsCodec: const StandardMessageCodec(), - creationParams: _configuration(), - onPlatformViewCreated: _created, - ), - ); -} diff --git a/lib/app/native_glass_surface.dart b/lib/app/native_glass_surface.dart new file mode 100644 index 00000000..a500f555 --- /dev/null +++ b/lib/app/native_glass_surface.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../theme/app_theme.dart'; + +/// 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. +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 colors = context.colors; + final dark = colors.background.computeLuminance() < 0.5; + return { + 'tint': colors.navBar.withValues(alpha: dark ? 0.12 : 0.36).toARGB32(), + '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/native_bottom_tab_bar_test.dart b/test/native_bottom_tab_bar_test.dart deleted file mode 100644 index b084e2a9..00000000 --- a/test/native_bottom_tab_bar_test.dart +++ /dev/null @@ -1,132 +0,0 @@ -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_bottom_tab_bar.dart'; -import 'package:mithka/components/app_icons.dart'; -import 'package:mithka/theme/app_theme.dart'; - -void main() { - testWidgets( - 'native tabs keep stable IDs, owned icons, badges, and live sizing', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - try { - const items = [ - NativeBottomTabItem( - id: 0, - label: 'Messages', - icon: HeroAppIcons.solidMessage, - ), - NativeBottomTabItem( - id: 2, - label: 'Contacts', - icon: HeroAppIcons.users, - ), - ]; - Map? created; - var id = -1; - final updates = []; - final messenger = tester.binding.defaultBinaryMessenger; - messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( - call, - ) async { - if (call.method == 'create') { - final arguments = call.arguments as Map; - id = arguments['id'] as int; - created = - const StandardMessageCodec().decodeMessage( - ByteData.sublistView(arguments['params'] as Uint8List), - ) - as Map; - messenger.setMockMethodCallHandler( - MethodChannel('mithka/native_bottom_bar/$id'), - (call) async { - updates.add(call); - return null; - }, - ); - } - return null; - }); - addTearDown(() { - messenger.setMockMethodCallHandler( - SystemChannels.platform_views, - null, - ); - messenger.setMockMethodCallHandler( - MethodChannel('mithka/native_bottom_bar/$id'), - null, - ); - }); - var selected = 0; - var clears = 0; - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(extensions: [AppColors.light]), - home: Align( - alignment: Alignment.bottomCenter, - child: NativeBottomTabBar( - items: items, - selection: 0, - unread: 252, - unreadLabel: '99+', - onSelect: (index) => selected = index, - onClearUnread: () => clears++, - ), - ), - ), - ); - await tester.pump(); - expect(id, greaterThanOrEqualTo(0)); - expect(created!['unread'], 252); - expect(created!['unreadLabel'], '99+'); - final nativeItems = created!['items'] as List; - expect((nativeItems[1] as Map)['id'], 2); - expect( - (nativeItems[1] as Map)['codePoint'], - HeroAppIcons.users.data.codePoint, - ); - expect( - (nativeItems[1] as Map)['fontFamily'], - HeroAppIcons.users.data.fontFamily, - ); - expect(updates.last.method, 'update'); - - Future nativeCall(String method, Object? value) async { - await messenger.handlePlatformMessage( - 'mithka/native_bottom_bar/$id', - const StandardMethodCodec().encodeMethodCall( - MethodCall(method, value), - ), - (_) {}, - ); - await tester.pump(); - } - - await nativeCall('select', 2); - expect(selected, 1); - await nativeCall('select', 99); - expect( - selected, - 1, - reason: 'ignore callbacks for a removed or stale tab', - ); - await nativeCall('clearUnread', null); - expect(clears, 1); - await nativeCall('height', 92.0); - expect( - tester - .getSize(find.byKey(const ValueKey('native-bottom-tab-bar'))) - .height, - 92, - ); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - expect(tester.takeException(), isNull); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }, - ); -} diff --git a/test/native_glass_bar_geometry_test.dart b/test/native_glass_bar_geometry_test.dart new file mode 100644 index 00000000..42d6eac0 --- /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 00000000..29295f39 --- /dev/null +++ b/test/native_glass_surface_test.dart @@ -0,0 +1,182 @@ +// +// 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(); + + final surfaceContext = tester.element(find.byType(NativeGlassSurface)); + final colors = surfaceContext.colors; + 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), + colors.navBar.withValues(alpha: 0.36).toARGB32(), + ); + + // 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, + ); + final darkColors = tester.element(find.byType(NativeGlassSurface)).colors; + 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), + darkColors.navBar.withValues(alpha: 0.12).toARGB32(), + ); + + 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; + } + }); +} diff --git a/test/tablet_glass_bar_native_fallback_test.dart b/test/tablet_glass_bar_native_fallback_test.dart deleted file mode 100644 index 3d1a714f..00000000 --- a/test/tablet_glass_bar_native_fallback_test.dart +++ /dev/null @@ -1,202 +0,0 @@ -// -// tablet_glass_bar_native_fallback_test.dart -// -// The system glass bar (NativeBottomTabBar) is a window-bottom bar: UIKit -// paints its selection capsule against its own slot metrics, so hosting it -// in a tablet split shell's narrow sidebar column misplaces the highlight -// over the tabs. The split shell must draw the app-owned glass bar, whose -// indicator follows the real tab slots; the compact phone bar keeps the -// system glass. -// - -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'; - -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 draws the app glass bar, not the system bar', ( - tester, - ) async { - await onIOS(() async { - await _pumpShell(tester, const Size(900, 800)); - // The system bar would render its selection capsule against the window - // bottom bar's own metrics; only the app-owned bar follows the sidebar's - // tab slots. - expect(find.byType(LiquidGlassBottomBar), findsOneWidget); - expect(find.byKey(const ValueKey('native-bottom-tab-bar')), findsNothing); - final indicator = find.byKey(const ValueKey('liquid-glass-selection')); - expect(indicator, findsOneWidget); - // The indicator covers exactly the first tab slot of the sidebar column. - final slots = find.byKey(const ValueKey('bottom-tab-0')); - expect(tester.getRect(indicator), tester.getRect(slots)); - await _dispose(tester); - }); - }); - - testWidgets('compact iOS window keeps the system glass bar', (tester) async { - await onIOS(() async { - final native = _MockNativeBar(); - await native.install(tester); - addTearDown(native.dispose); - await _pumpShell(tester, const Size(390, 844)); - expect( - find.byKey(const ValueKey('native-bottom-tab-bar')), - findsOneWidget, - ); - expect(find.byType(LiquidGlassBottomBar), findsNothing); - await _dispose(tester); - }); - }); -} - -/// Records the platform view creation and answers the per-bar channel, the -/// way the native UIKit bar does under flutter test. -class _MockNativeBar { - int? _viewId; - TestDefaultBinaryMessenger? _messenger; - - Future install(WidgetTester tester) async { - _messenger = tester.binding.defaultBinaryMessenger; - _messenger!.setMockMethodCallHandler(SystemChannels.platform_views, ( - call, - ) async { - if (call.method == 'create') { - final arguments = call.arguments as Map; - _viewId = arguments['id'] as int; - _messenger!.setMockMethodCallHandler( - MethodChannel('mithka/native_bottom_bar/$_viewId'), - (call) async => null, - ); - } - return null; - }); - } - - void dispose() { - _messenger?.setMockMethodCallHandler(SystemChannels.platform_views, null); - if (_viewId != null) { - _messenger?.setMockMethodCallHandler( - MethodChannel('mithka/native_bottom_bar/$_viewId'), - null, - ); - } - } -} - -/// 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) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = size; - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - 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], - ), - 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'}, - };