Files
buzz/mobile/lib/shared/widgets/ios_glass_navigation_button.dart
klopez4212 27b9c827fc Polish mobile message layout and navigation controls (#8427)
Align outgoing messages and galleries to the right, reclaim the outgoing
avatar gutter, and match desktop calendar dividers with balanced
spacing. Standardize navigation and sheet icons at 24 points, restore
native Edit/Done styling, and align section menus with trailing channel
indicators.

Validation: clean analysis; 3,513 mobile tests and push-configuration
checks passed; iPhone Release build installed and tested by the owner.
Includes regression coverage for message placement, date spacing,
gallery direction, and section alignment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 15:17:25 -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
: 24,
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),
),
),
);
}
}