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'}, - };