mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user