Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
9A4F20F231E1000100000001 /* NowPlayingBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20F131E1000100000001 /* NowPlayingBridge.swift */; };
9A4F20EA31E1000100000001 /* CommunicationNotificationBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20E931E1000100000001 /* CommunicationNotificationBridge.swift */; };
9A4F20EC31E1000100000001 /* ApplePCCBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */; };
AC39B11231E1000100000001 /* NativeGlassSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC39B11331E1000100000001 /* NativeGlassSurface.swift */; };
A17D0FF131E1000100000001 /* HandoffBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17D0FF031E1000100000001 /* HandoffBridge.swift */; };
A685F7A8DE8A8339A7CFE48A /* RunnerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8FADD48E0FE94429FDE1912 /* RunnerUITests.swift */; };
B005C7BA5D2714CB149B4B95 /* MithkaBlue.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0CC7151EAA772074B2F1F159 /* MithkaBlue.icon */; };
Expand Down Expand Up @@ -134,6 +135,7 @@
9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplePCCBridge.swift; sourceTree = "<group>"; };
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 = "<group>"; };
A17D0FF031E1000100000001 /* HandoffBridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HandoffBridge.swift; path = ../../apple/HandoffBridge.swift; sourceTree = "<group>"; };
AC39B11331E1000100000001 /* NativeGlassSurface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeGlassSurface.swift; sourceTree = "<group>"; };
A257B729519AB240FBD5E29F /* MithkaPurple.icon */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.iconcomposer.icon; path = MithkaPurple.icon; sourceTree = "<group>"; };
B02F5C04823E6F1B8B4DC318 /* NotificationCommunicationContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCommunicationContent.swift; sourceTree = "<group>"; };
C145FCC43A8CB4B5D827E2D6 /* MithkaPixel.icon */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.iconcomposer.icon; path = MithkaPixel.icon; sourceTree = "<group>"; };
Expand Down Expand Up @@ -273,6 +275,7 @@
9A4F20F131E1000100000001 /* NowPlayingBridge.swift */,
9A4F20E931E1000100000001 /* CommunicationNotificationBridge.swift */,
9A4F20EB31E1000100000001 /* ApplePCCBridge.swift */,
AC39B11331E1000100000001 /* NativeGlassSurface.swift */,
A17D0FF031E1000100000001 /* HandoffBridge.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
Expand Down Expand Up @@ -699,6 +702,7 @@
9A4F20F231E1000100000001 /* NowPlayingBridge.swift in Sources */,
9A4F20EA31E1000100000001 /* CommunicationNotificationBridge.swift in Sources */,
9A4F20EC31E1000100000001 /* ApplePCCBridge.swift in Sources */,
AC39B11231E1000100000001 /* NativeGlassSurface.swift in Sources */,
A17D0FF131E1000100000001 /* HandoffBridge.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
Expand Down
3 changes: 3 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ import UserNotifications
guard !didRegisterFlutterPlugins else { return }
didRegisterFlutterPlugins = true
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "NativeGlassSurface") {
registrar.register(NativeGlassSurfaceFactory(registrar: registrar), withId: "mithka/native_glass")
}
let windowGeometryChannel = FlutterMethodChannel(
name: "mithka/window_geometry",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
Expand Down
118 changes: 118 additions & 0 deletions ios/Runner/NativeGlassSurface.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Flutter
import UIKit

/// A glass-surface-only platform view behind the app-owned tab row:
///
/// - On iOS 26+ the glass is the public `UIGlassEffect(style: .regular)` inside
/// a `UIVisualEffectView`, styled with `overrideUserInterfaceStyle` and
/// `tintColor` from the active theme.
/// - Before iOS 26 it uses a system material `UIBlurEffect`.
/// - The view draws no tabs, icons, or badges, and disables user interaction so
/// every tap lands on the Flutter tab controls above it.
@MainActor
final class NativeGlassSurfaceFactory: NSObject, FlutterPlatformViewFactory {
private let registrar: FlutterPluginRegistrar

init(registrar: FlutterPluginRegistrar) {
self.registrar = registrar
super.init()
}

func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}

func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
NativeGlassSurface(frame: frame, id: viewId, registrar: registrar, arguments: args)
}
}

@MainActor
private final class NativeGlassSurface: NSObject, FlutterPlatformView {
private let host: GlassSurfaceHost
private let channel: FlutterMethodChannel

init(frame: CGRect, id: Int64, registrar: FlutterPluginRegistrar, arguments: Any?) {
host = GlassSurfaceHost(frame: frame)
channel = FlutterMethodChannel(
name: "mithka/native_glass/\(id)", binaryMessenger: registrar.messenger()
)
super.init()
if let configuration = arguments as? [String: Any] { host.apply(configuration) }
channel.setMethodCallHandler { [weak self] call, result in
guard let self, call.method == "update",
let configuration = call.arguments as? [String: Any]
else {
result(FlutterMethodNotImplemented)
return
}
self.host.apply(configuration)
result(nil)
}
}

func view() -> UIView { host }

deinit { channel.setMethodCallHandler(nil) }
}

@MainActor
private final class GlassSurfaceHost: UIView {
private let effectView = UIVisualEffectView(effect: nil)

override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
effectView.isUserInteractionEnabled = false
effectView.layer.masksToBounds = true
addSubview(effectView)
}

required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

func apply(_ configuration: [String: Any]) {
let dark = configuration["dark"] as? Bool == true
overrideUserInterfaceStyle = dark ? .dark : .light
effectView.overrideUserInterfaceStyle = dark ? .dark : .light
let cornerRadius = (configuration["radius"] as? NSNumber)?.doubleValue ?? 0
effectView.layer.cornerRadius = cornerRadius

if #available(iOS 26.0, *) {
var tint: UIColor?
if let argb = configuration["tint"] as? NSNumber {
let value = argb.uint32Value
tint = UIColor(
red: CGFloat((value >> 16) & 255) / 255,
green: CGFloat((value >> 8) & 255) / 255,
blue: CGFloat(value & 255) / 255,
alpha: CGFloat((value >> 24) & 255) / 255
)
}
// UIGlassEffect resolves its tint when the material is built, so
// mutating the installed effect is a no-op assignment: a tint change
// installs a fresh effect instead.
if let current = effectView.effect as? UIGlassEffect, current.tintColor == tint {
// Same tint: keep the built material.
} else {
let glass = UIGlassEffect(style: .regular)
glass.tintColor = tint
glass.isInteractive = false
effectView.effect = glass
}
} else {
let desiredStyle: UIBlurEffect.Style = dark ? .systemMaterialDark : .systemMaterialLight
effectView.effect = UIBlurEffect(style: desiredStyle)
}
}

override func layoutSubviews() {
super.layoutSubviews()
effectView.frame = bounds
}
}
7 changes: 6 additions & 1 deletion lib/app/liquid_glass_bottom_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ class GlassSelectionIndicator extends StatelessWidget {
child: DecoratedBox(
key: const ValueKey('liquid-glass-selection'),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.control),
// A stadium concentric with the capsule: the row sits a
// 5px inset inside the capsule's stadium, so this stadium's
// end arcs share the capsule's centres and the two edges
// stay parallel everywhere. A small rectangle radius would
// read as a different shape from the capsule around it.
borderRadius: BorderRadius.circular(AppRadius.pill),
color: colors.linkBlue.withValues(
alpha: highContrast ? 0.22 : (dark ? 0.19 : 0.10),
),
Expand Down
106 changes: 97 additions & 9 deletions lib/app/main_tab_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import 'desktop_utility_window.dart';
import 'detail_content_reveal.dart';
import 'horizontal_safe_viewport.dart';
import 'liquid_glass_bottom_bar.dart';
import 'native_glass_surface.dart';
import 'primary_chat_launcher.dart';
import 'unread_badge_model.dart';

Expand Down Expand Up @@ -2159,6 +2160,53 @@ double homeIndicatorBarClearance(BuildContext context) {
return math.min(reported, kHomeIndicatorClearance);
}

/// The silhouette the native glass capsule cannot give itself.
///
/// iOS 26's material derives its visible edge from a backdrop-luminance clamp
/// that only private UIKit reaches (Telegram swizzles `_UISDFBackdropView`;
/// App Store Guideline 2.5.1 scans binaries for those class-name strings, so
/// it is off-limits here). The public alternative: draw the edge ourselves.
/// This frame wraps the native surface and paints the same hairline contour
/// and drop shadow the Flutter glass bar uses — a dark 0.6pt outline (5.5%
/// black in light, 34% in dark) around the stadium, foreground so the glass
/// clipped inside cannot cover it — while the surface keeps the refraction.
class _GlassCapsuleFrame extends StatelessWidget {
const _GlassCapsuleFrame({required this.child});

final Widget child;

@override
Widget build(BuildContext context) {
final colors = context.colors;
final dark = colors.navBar.computeLuminance() < 0.5;
const black = Color(0xFF000000);
const radius = BorderRadius.all(Radius.circular(AppRadius.pill));
final contour = black.withValues(alpha: dark ? 0.34 : 0.055);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: [
BoxShadow(
color: black.withValues(alpha: dark ? 0.24 : 0.09),
blurRadius: 20,
offset: const Offset(0, 5),
),
],
),
child: DecoratedBox(
// Foreground so the contour sits on the capsule's outer edge instead
// of being covered by the glass clipped inside it.
position: DecorationPosition.foreground,
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: contour, width: 0.6),
),
child: child,
),
);
}
}

/// Shared tab controls with an optional glass surface.
class _MainBottomBar extends StatelessWidget {
const _MainBottomBar({
Expand Down Expand Up @@ -2314,11 +2362,12 @@ class _MainBottomBar extends StatelessWidget {
child: AppInteractiveSurface(
key: ValueKey('bottom-tab-${items[i].index}'),
// Match the selection indicator's corner radius so the tap
// ripple, hover tint, and highlight share one slot shape. A
// full stadium would clip the label's corners (see
// LiquidGlassBottomBar).
// ripple, hover tint, and highlight share one slot shape:
// a stadium concentric with the capsule. The glass row is
// icon-only (no label line to clip at the arc), and the icon
// block clears the arc even in the narrowest tablet slot.
borderRadius: glass
? BorderRadius.circular(AppRadius.control)
? BorderRadius.circular(AppRadius.pill)
: BorderRadius.zero,
semanticLabel: items[i].label.l10n(context),
selected: selection == i,
Expand Down Expand Up @@ -2429,11 +2478,50 @@ class _MainBottomBar extends StatelessWidget {
);
final controls = controlsWithBottomInset(0.0);
if (glass) {
// One Flutter glass bar serves every target, including iOS: the shell
// blurs its own layer tree, so the bar refracts the content that
// actually scrolls behind it. The row, its selection pill, and every
// gesture stay app-owned too, which keeps the compact phone bar and the
// tablet split shell's sidebar column on one geometry source.
// iOS hosts the real system Liquid Glass: a native surface draws only
// the glass (public UIGlassEffect on iOS 26+, system material blur
// before), while the app-owned row, its selection pill, and every
// gesture stay in Flutter above the surface — one geometry source in
// any host, window bottom bar or sidebar column. The tint follows
// Telegram iOS (white 0.10 light / 0.025 dark) so the material itself
// stays visible; the Flutter-drawn contour and shadow below give the
// capsule its edge on white.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
// A platform view has no insets of its own, so the host repeats the
// exact containment chain the Flutter glass bar applies: the surface
// stops at the bottom safe area instead of reaching under the home
// indicator, and the shared lateral/top/capsule margins keep the row
// inside the stadium at the same rect either host draws it at.
return SafeArea(
key: const ValueKey('liquid-glass-bottom-bar'),
top: false,
minimum: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 0),
child: _GlassCapsuleFrame(
child: NativeGlassSurface(
child: Padding(
padding: const EdgeInsets.all(5),
child: Stack(
children: [
Positioned.fill(
child: GlassSelectionIndicator(
selection: selection,
itemCount: items.length,
),
),
controls,
],
),
),
),
),
),
);
}
// Every other target keeps the Flutter glass bar: the shell blurs its
// own layer tree, so the bar refracts the content that actually scrolls
// behind it.
return LiquidGlassBottomBar(
selection: selection,
itemCount: items.length,
Expand Down
Loading
Loading