Files
buzz/mobile/lib/shared/widgets/ios_glass_navigation_button.dart
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00

318 lines
11 KiB
Dart

import 'package:buzz/shared/theme/buzz_icons.dart';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
/// The navigation glyph displayed by [IosGlassNavigationButton].
enum IosGlassNavigationIcon {
back,
close,
camera,
photoLibrary,
palette,
droplet,
emoji,
person,
frame,
rotateCamera,
shutter,
colorSwatch,
sun,
moon,
systemAppearance,
more,
reply,
}
/// Leading width used by iOS channel-style headers.
const iosGlassChannelHeaderLeadingWidth = 58.0;
/// Horizontal center of the native button inside a channel-style leading.
const iosGlassChannelHeaderButtonCenterX = 38.0;
/// Space between the leading region and a channel-style title.
const iosGlassChannelHeaderTitleSpacing = Grid.xs;
/// A native iOS navigation control using the system glass button treatment.
///
/// Callers should only insert this widget on iOS and retain their existing
/// Flutter control on other platforms.
class IosGlassNavigationButton extends HookWidget {
const IosGlassNavigationButton({
super.key,
required this.icon,
required this.semanticLabel,
required this.onPressed,
this.label,
this.width = 48,
this.height = 48,
this.controlSize = 40,
this.fillWidth = false,
this.buttonCenterX,
this.foregroundColor,
this.swatchColor,
this.isBusy = false,
this.isSelected = false,
this.nativeViewSuppressed,
});
static const viewType = 'buzz/navigation_glass';
/// The SF Symbol-style glyph rendered by the native control.
final IosGlassNavigationIcon icon;
/// Optional text rendered by the native control instead of [icon].
final String? label;
/// Accessibility label exposed by the native view or Flutter fallback.
final String semanticLabel;
/// Invoked when the enabled control is activated.
final VoidCallback? onPressed;
/// Width of the platform-view hit target.
final double width;
/// Height of the platform-view hit target.
final double height;
/// Diameter of the visual glass control inside its hit target.
final double controlSize;
/// Whether the native visual control fills the available width.
final bool fillWidth;
/// Horizontal center for the visual control within its hit target.
final double? buttonCenterX;
/// Optional foreground tint for the native control and Flutter fallback.
final Color? foregroundColor;
/// Optional inset color swatch used by [IosGlassNavigationIcon.colorSwatch].
final Color? swatchColor;
/// Whether the native control presents its busy state.
final bool isBusy;
/// Whether the native control exposes its selected state.
final bool isSelected;
/// When true, substitutes an accessible Flutter control for the native view.
final ValueListenable<bool>? nativeViewSuppressed;
@override
Widget build(BuildContext context) {
assert(defaultTargetPlatform == TargetPlatform.iOS);
final nativeChannel = useState<MethodChannel?>(null);
final onPressedRef = useRef(onPressed)..value = onPressed;
final brightness = context.theme.brightness.name;
final effectiveForeground = foregroundColor ?? context.colors.primary;
final foregroundValue = effectiveForeground.toARGB32();
final swatchColorValue = swatchColor?.toARGB32();
final enabled = onPressed != null;
useEffect(() {
final channel = nativeChannel.value;
if (channel == null) return null;
channel.setMethodCallHandler((call) async {
if (call.method == 'pressed') {
onPressedRef.value?.call();
}
});
return () => channel.setMethodCallHandler(null);
}, [nativeChannel.value]);
useEffect(
() {
final channel = nativeChannel.value;
if (channel != null) {
unawaited(
channel.invokeMethod<void>('setAppearance', <String, Object>{
'brightness': brightness,
'foregroundColor': foregroundValue,
'enabled': enabled,
'busy': isBusy,
'selected': isSelected,
'swatchColor': ?swatchColorValue,
}),
);
}
return null;
},
[
nativeChannel.value,
brightness,
foregroundValue,
enabled,
isBusy,
isSelected,
swatchColorValue,
],
);
useEffect(() {
final channel = nativeChannel.value;
if (channel != null) {
final content = <String, Object>{
'icon': icon.name,
'accessibilityLabel': semanticLabel,
};
if (label != null) content['label'] = label!;
unawaited(channel.invokeMethod<void>('setContent', content));
}
return null;
}, [nativeChannel.value, icon, label, semanticLabel]);
Widget buildControl({required bool suppressNativeView}) {
if (suppressNativeView) {
final resolvedButtonCenterX = buttonCenterX ?? width / 2;
return Semantics(
container: true,
button: true,
enabled: enabled,
selected: isSelected,
label: semanticLabel,
onTap: onPressed,
child: ExcludeSemantics(
child: Stack(
key: const ValueKey('ios-glass-navigation-flutter-fallback'),
children: [
Positioned(
left: resolvedButtonCenterX - controlSize / 2,
top: (height - controlSize) / 2,
width: controlSize,
height: controlSize,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.72),
shape: BoxShape.circle,
border: Border.all(
color: context.colors.inverseSurface.withValues(
alpha: 0.07,
),
),
),
child: isBusy
? Center(
child: SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: effectiveForeground,
),
),
)
: icon == IosGlassNavigationIcon.colorSwatch
? Padding(
padding: const EdgeInsets.all(4),
child: DecoratedBox(
decoration: BoxDecoration(
color: swatchColor,
shape: BoxShape.circle,
),
),
)
: label != null
? Text(
label!,
maxLines: 1,
style: context.textTheme.labelMedium?.copyWith(
color: effectiveForeground,
fontWeight: FontWeight.w600,
),
)
: Icon(
switch (icon) {
IosGlassNavigationIcon.more => BuzzIcons.ellipsis,
IosGlassNavigationIcon.reply =>
BuzzIcons.cornerUpLeft,
IosGlassNavigationIcon.back =>
BuzzIcons.chevronLeft,
IosGlassNavigationIcon.close => BuzzIcons.x,
IosGlassNavigationIcon.camera => BuzzIcons.camera,
IosGlassNavigationIcon.photoLibrary =>
BuzzIcons.images,
IosGlassNavigationIcon.palette =>
BuzzIcons.palette,
IosGlassNavigationIcon.droplet =>
BuzzIcons.droplet,
IosGlassNavigationIcon.emoji => BuzzIcons.smile,
IosGlassNavigationIcon.person => BuzzIcons.user,
IosGlassNavigationIcon.frame => BuzzIcons.square,
IosGlassNavigationIcon.rotateCamera =>
BuzzIcons.switchCamera,
IosGlassNavigationIcon.shutter =>
BuzzIcons.circle,
IosGlassNavigationIcon.colorSwatch =>
BuzzIcons.circle,
IosGlassNavigationIcon.sun => BuzzIcons.sun,
IosGlassNavigationIcon.moon => BuzzIcons.moon,
IosGlassNavigationIcon.systemAppearance =>
BuzzIcons.sunMoon,
},
size: icon == IosGlassNavigationIcon.shutter
? controlSize * 0.72
: 22,
color: icon == IosGlassNavigationIcon.colorSwatch
? swatchColor
: effectiveForeground,
),
),
),
],
),
),
);
}
final creationParams = <String, Object>{
'icon': icon.name,
'accessibilityLabel': semanticLabel,
'brightness': brightness,
'foregroundColor': foregroundValue,
'enabled': enabled,
'busy': isBusy,
'selected': isSelected,
'controlSize': controlSize,
'controlWidth': controlSize,
'fillWidth': fillWidth,
'buttonCenterX': buttonCenterX ?? width / 2,
'hitTargetWidth': width,
'hitTargetHeight': height,
'swatchColor': ?swatchColorValue,
};
if (label != null) creationParams['label'] = label!;
return UiKitView(
viewType: viewType,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
nativeChannel.value = MethodChannel('$viewType/$viewId');
},
);
}
return Tooltip(
message: semanticLabel,
excludeFromSemantics: true,
child: SizedBox(
width: width,
height: height,
child: nativeViewSuppressed == null
? buildControl(suppressNativeView: false)
: ValueListenableBuilder<bool>(
valueListenable: nativeViewSuppressed!,
builder: (context, suppressNativeView, _) =>
buildControl(suppressNativeView: suppressNativeView),
),
),
);
}
}