feat(mobile): add iOS Liquid Glass tabs and quick actions (#8080)

## Summary

Use native Liquid Glass for the iOS tab bar and expanding + menu,
preserving the existing Lucide icons, icon-only layout, badges,
accessibility labels, and tab interactions. The + uses black-tinted
glass with white controls; the selected tab has a subtle theme accent
(6% blended into the existing selection color). Android retains its
current styling.

### Related issue

No duplicate issue found. Related prior navigation work: #7281 (closed).
This change covers the tab bar and quick actions; it does not add
page-to-page navigation-bar morphing.

### Testing

- `just mobile-check` passed (formatting, analysis, and file-size
checks).
- `just mobile-test` passed: 2,678 tests passed, 4 skipped; the separate
unconfigured-push suite also passed all 3 tests.
- Widget coverage verifies native glass parameters, black + tint,
original icons, icon-only layout, tab selection, badges, and quick-menu
open/close behavior.
- Signed Release iOS build installed and launched on a physical iPhone;
the final glass and tint styling was reviewed on device. No final-app
screenshot was captured.
- Full `just ci` was attempted but stopped during desktop compilation as
available disk space dropped to 3.5 GiB. The complete repository-wide
gate is not locally verified; hosted CI remains pending.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-04 14:24:09 +01:00
committed by GitHub
parent d2da104d94
commit 03f24a69bf
6 changed files with 276 additions and 89 deletions

View File

@@ -76,7 +76,8 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
updateColors(
colorValue: colorValue,
backdropColorValue: backdropColorValue
backdropColorValue: backdropColorValue,
glassTintColorValue: (arguments?["glassTintColor"] as? NSNumber)?.uint32Value
)
applyInterfaceStyle(from: arguments?["brightness"])
applyCornerConfiguration(minimumRadius: minimumRadius)
@@ -104,7 +105,8 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
self.updateColors(
colorValue: colorValue.uint32Value,
backdropColorValue: (arguments["backdropColor"] as? NSNumber)?.uint32Value
backdropColorValue: (arguments["backdropColor"] as? NSNumber)?.uint32Value,
glassTintColorValue: (arguments["glassTintColor"] as? NSNumber)?.uint32Value
)
result(nil)
case "updateGeometry":
@@ -140,9 +142,17 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
rootView
}
private func updateColors(colorValue: UInt32, backdropColorValue: UInt32?) {
private func updateColors(
colorValue: UInt32,
backdropColorValue: UInt32?,
glassTintColorValue: UInt32?
) {
let surfaceColor = Self.color(from: colorValue)
if let glassView = surfaceView as? UIVisualEffectView {
if #available(iOS 26.0, *), let effect = glassView.effect as? UIGlassEffect {
effect.tintColor = glassTintColorValue.map { Self.color(from: $0) }
glassView.effect = effect
}
glassView.backgroundColor = .clear
glassView.contentView.backgroundColor = surfaceColor.withAlphaComponent(0.12)
} else {

View File

@@ -22,6 +22,7 @@ import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/concentric_sheet_surface.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/ios_navigation_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';

View File

@@ -122,18 +122,8 @@ class _MorphingQuickActionsButton extends HookWidget {
key: const Key('quick-actions-surface'),
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.24),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: _QuickActionsSurface(
radius: radius,
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
@@ -221,7 +211,11 @@ class _MorphingQuickActionsButton extends HookWidget {
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
color:
defaultTargetPlatform ==
TargetPlatform.iOS
? Colors.white
: context.colors.onPrimary,
),
),
),
@@ -247,6 +241,45 @@ class _MorphingQuickActionsButton extends HookWidget {
}
}
class _QuickActionsSurface extends StatelessWidget {
const _QuickActionsSurface({required this.radius, required this.child});
final double radius;
final Widget child;
@override
Widget build(BuildContext context) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
return DecoratedBox(
decoration: BoxDecoration(
color: isIos ? null : context.colors.primary,
borderRadius: BorderRadius.circular(radius),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: isIos ? 0.10 : 0.24),
blurRadius: isIos ? 20 : 12,
offset: Offset(0, isIos ? 8 : 4),
),
],
),
child: isIos
? ConcentricSheetSurface(
key: const ValueKey('quick-actions-ios-glass'),
enabled: true,
usesGlass: true,
glassTintColor: Colors.black,
color: Colors.black,
providesSheetSurface: false,
padding: EdgeInsets.zero,
minimumRadius: radius,
contentClipRadius: radius,
child: child,
)
: child,
);
}
}
class _QuickActionsMenu extends StatelessWidget {
final ValueChanged<_QuickAction> onSelected;
@@ -301,11 +334,14 @@ class _QuickActionItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final foreground = context.colors.onPrimary;
final background = Color.alphaBlend(
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
context.colors.primary,
);
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final foreground = isIos ? Colors.white : context.colors.onPrimary;
final background = isIos
? foreground.withValues(alpha: _kQuickActionCardOverlayOpacity)
: Color.alphaBlend(
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
context.colors.primary,
);
return Expanded(
child: Material(

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -8,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/concentric_sheet_surface.dart';
import '../../shared/widgets/directional_transition_scope.dart';
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
import '../activity/activity_page.dart';
@@ -331,6 +333,91 @@ class _FloatingTabBar extends StatelessWidget {
destinationCount,
);
final content = Padding(
padding: const EdgeInsets.all(HomePage._tabBarInnerInset),
child: SizedBox(
height: HomePage._tabBarHeight - (HomePage._tabBarInnerInset * 2),
width: destinationWidth * destinationCount,
child: Stack(
children: [
AnimatedAlign(
alignment: selectedAlignment,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: SizedBox(
width: destinationWidth,
height: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
color: defaultTargetPlatform == TargetPlatform.iOS
? Color.alphaBlend(
colorScheme.primary.withValues(alpha: 0.06),
colorScheme.primaryContainer,
)
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(
HomePage._selectedTabRadius,
),
),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < destinations.length; i++)
SizedBox(
width: destinationWidth,
child: _FloatingTabDestination(
destination: destinations[i],
selected: i == safeSelectedIndex,
showUnreadBadge: i == 1 && hasUnreadInbox,
onTap: () => onDestinationSelected(i),
),
),
],
),
],
),
),
);
final surface = defaultTargetPlatform == TargetPlatform.iOS
? ConcentricSheetSurface(
key: const ValueKey('home-ios-glass-tabs'),
enabled: true,
usesGlass: true,
providesSheetSurface: false,
padding: EdgeInsets.zero,
minimumRadius: HomePage._tabBarRadius,
contentClipRadius: HomePage._tabBarRadius,
child: content,
)
: ClipRRect(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
color: isDark
? colorScheme.surfaceContainerHighest.withValues(
alpha: 0.72,
)
: colorScheme.surface,
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: isDark ? 0.20 : 0.38,
),
),
),
child: content,
),
),
);
return SafeArea(
minimum: const EdgeInsets.fromLTRB(
HomePage._tabBarHorizontalMargin,
@@ -352,74 +439,7 @@ class _FloatingTabBar extends StatelessWidget {
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
color: isDark
? colorScheme.surfaceContainerHighest.withValues(
alpha: 0.72,
)
: colorScheme.surface,
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: isDark ? 0.20 : 0.38,
),
),
),
child: Padding(
padding: const EdgeInsets.all(HomePage._tabBarInnerInset),
child: SizedBox(
height:
HomePage._tabBarHeight -
(HomePage._tabBarInnerInset * 2),
width: destinationWidth * destinationCount,
child: Stack(
children: [
AnimatedAlign(
alignment: selectedAlignment,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: SizedBox(
width: destinationWidth,
height: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(
HomePage._selectedTabRadius,
),
),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < destinations.length; i++)
SizedBox(
width: destinationWidth,
child: _FloatingTabDestination(
destination: destinations[i],
selected: i == safeSelectedIndex,
showUnreadBadge: i == 1 && hasUnreadInbox,
onTap: () => onDestinationSelected(i),
),
),
],
),
],
),
),
),
),
),
),
child: surface,
),
),
);

View File

@@ -26,6 +26,7 @@ class ConcentricSheetSurface extends HookWidget {
),
this.providesSheetSurface = true,
this.usesGlass = false,
this.glassTintColor,
this.minimumRadius = Radii.dialog,
this.contentClipRadius,
super.key,
@@ -39,6 +40,9 @@ class ConcentricSheetSurface extends HookWidget {
final EdgeInsetsGeometry padding;
final bool providesSheetSurface;
final bool usesGlass;
/// Optional tint applied to the native Liquid Glass material.
final Color? glassTintColor;
final double minimumRadius;
final double? contentClipRadius;
@@ -80,6 +84,7 @@ class ConcentricSheetSurface extends HookWidget {
await channel.invokeMethod<void>('updateColors', <String, Object?>{
'color': surfaceColor.toARGB32(),
'backdropColor': backdropColor?.toARGB32(),
'glassTintColor': glassTintColor?.toARGB32(),
});
} on MissingPluginException {
// The platform view may have been disposed while its theme was changing.
@@ -142,6 +147,7 @@ class ConcentricSheetSurface extends HookWidget {
nativeSurfaceChannel.value,
surfaceColor,
backdropColor,
glassTintColor,
],
);
useEffect(
@@ -199,6 +205,8 @@ class ConcentricSheetSurface extends HookWidget {
'minimumRadius': minimumRadius,
'corners': corners.name,
'usesGlass': usesGlass,
if (glassTintColor case final tint?)
'glassTintColor': tint.toARGB32(),
'brightness': brightness.name,
},
creationParamsCodec: const StandardMessageCodec(),

View File

@@ -2,7 +2,9 @@ import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -34,12 +36,122 @@ void main() {
);
}
testWidgets('iOS glass retains icons, tab taps and unread semantics', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const support = MethodChannel('buzz/concentric_sheet_surface');
final created = <Map<Object?, Object?>>[];
messenger.setMockMethodCallHandler(support, (call) async => true);
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
call,
) async {
if (call.method == 'create') {
final args = call.arguments as Map<Object?, Object?>;
if (args['viewType'] == 'buzz/concentric_sheet_surface') {
created.add(
const StandardMessageCodec().decodeMessage(
ByteData.sublistView(args['params'] as Uint8List),
)
as Map<Object?, Object?>,
);
}
}
return null;
});
addTearDown(() {
messenger.setMockMethodCallHandler(support, null);
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
});
await tester.pumpWidget(await buildHome(unreadInboxCount: 2));
await tester.pump();
await tester.pump();
final bar = find.byKey(const ValueKey('home-ios-glass-tabs'));
expect(bar, findsOneWidget);
expect(tester.getSize(bar), const Size(218, 56));
expect(created.where((params) => params['usesGlass'] == true), isNotEmpty);
expect(
find.descendant(of: bar, matching: find.byIcon(LucideIcons.house500)),
findsOneWidget,
);
expect(
find.descendant(of: bar, matching: find.byIcon(LucideIcons.inbox300)),
findsOneWidget,
);
expect(find.bySemanticsLabel('Activity, unread'), findsOneWidget);
expect(find.descendant(of: bar, matching: find.byType(Text)), findsNothing);
final launcherGlass = find.byKey(const ValueKey('quick-actions-ios-glass'));
expect(launcherGlass, findsOneWidget);
expect(tester.getSize(launcherGlass), const Size.square(56));
expect(
created.where(
(params) => params['glassTintColor'] == Colors.black.toARGB32(),
),
hasLength(1),
);
expect(
tester
.widget<Icon>(
find.descendant(
of: launcherGlass,
matching: find.byIcon(LucideIcons.plus),
),
)
.color,
Colors.white,
);
expect(
created.where((params) => params['usesGlass'] == true).length,
greaterThanOrEqualTo(2),
);
expect(
find.descendant(
of: launcherGlass,
matching: find.byIcon(LucideIcons.plus),
),
findsOneWidget,
);
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(tester.getSize(launcherGlass).width, greaterThan(56));
expect(find.text('Create channel').hitTestable(), findsOneWidget);
expect(find.text('New direct message').hitTestable(), findsOneWidget);
expect(find.text('Browse channels').hitTestable(), findsOneWidget);
await tester.tapAt(const Offset(8, 8));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(tester.getSize(launcherGlass), const Size.square(56));
await tester.tap(find.byTooltip('Activity'));
await tester.pump(const Duration(milliseconds: 250));
await tester.pump();
expect(
find.descendant(of: bar, matching: find.byIcon(LucideIcons.inbox500)),
findsOneWidget,
);
expect(find.bySemanticsLabel('Activity, unread'), findsNothing);
await tester.tap(find.byTooltip('Search'));
await tester.pump(const Duration(milliseconds: 250));
await tester.pump();
expect(
find.descendant(of: bar, matching: find.byIcon(LucideIcons.search500)),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
debugDefaultTargetPlatformOverride = null;
});
testWidgets('shows icon-only navigation and an aligned quick action', (
tester,
) async {
await tester.pumpWidget(await buildHome());
await tester.pump();
expect(find.byKey(const ValueKey('home-ios-glass-tabs')), findsNothing);
expect(find.text('Home'), findsNothing);
expect(find.text('Activity'), findsNothing);
expect(find.text('Search'), findsNothing);