mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Mobile New chat now opens directly with the synced Bestie selected. The first send creates and names the chat; leaving an empty draft creates nothing. New and existing Solo chats can select or clear a workspace from the header popover, with a checkmark for the active choice and a No Workspace option. Workspace and agent controls have separate iOS glass surfaces, Bestie stays at the far right, and Back uses a chevron. The agent sheet fits its list, with scrolling for long lists. Workspace creation stays in the Home New menu. The New menu groups Solo above Team. Android expands across the full control row; iOS keeps its native menu while Search and Bestie merge into New through Liquid Glass, with synchronized icon motion and the residual glass fading away. Empty workspaces use a compact, muted message. Validation: 4,055 mobile tests passed (34 skipped), static analysis passed, and native menu checks passed. Earlier physical-device previews and the iPhone New-menu animation were visually approved before this consolidation. This PR consolidates the controls previously developed in #8891 into the existing draft/first-send implementation. The New menu animations, attachment handling, and durable retries remain in this PR. The combined controls have widget interaction coverage; the combined build has not yet had a new physical-device walkthrough. Current combined controls below are Android Flutter widget renders with fixture data, not device screenshots. Images are GitHub attachments only. | New conversation | Workspace popover | Compact agent sheet | | --- | --- | --- | |  |  |  | Empty workspace widget preview (unchanged):  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
211 lines
6.7 KiB
Dart
211 lines
6.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
import '../theme/theme.dart';
|
|
import 'avatar_image.dart';
|
|
|
|
/// The four desktop creation destinations, presented by UIKit on iOS.
|
|
class IosMessageCreateButton extends HookWidget {
|
|
const IosMessageCreateButton({
|
|
super.key,
|
|
required this.actions,
|
|
this.label,
|
|
this.labelFontSize,
|
|
this.avatarUrl,
|
|
this.avatarInitial,
|
|
this.dividerBefore = const {},
|
|
this.sectionTitles = const {},
|
|
this.icon = 'plus',
|
|
this.accessibilityLabel = 'Create',
|
|
this.width = 64,
|
|
this.height = 48,
|
|
this.plain = true,
|
|
this.fillWidth = false,
|
|
this.enabled = true,
|
|
this.selectedIndex,
|
|
this.eagerGestures = true,
|
|
this.deferMenuActions = true,
|
|
this.showIconWithLabel = false,
|
|
this.onMenuVisibilityChanged,
|
|
this.mergingNavigation = false,
|
|
});
|
|
|
|
final Set<int> dividerBefore;
|
|
final Map<int, String> sectionTitles;
|
|
final String? label;
|
|
final double? labelFontSize;
|
|
final String? avatarUrl, avatarInitial;
|
|
final String icon;
|
|
final String accessibilityLabel;
|
|
final double width, height;
|
|
final bool plain;
|
|
final bool fillWidth;
|
|
|
|
/// Whether the native trigger and its actions are interactive.
|
|
final bool enabled;
|
|
|
|
/// Option marked with the native menu selection checkmark.
|
|
final int? selectedIndex;
|
|
|
|
/// Capture gestures immediately; disable for rows inside a scroll view.
|
|
final bool eagerGestures;
|
|
|
|
/// Wait for menu dismissal before actions that navigate to another route.
|
|
final bool deferMenuActions;
|
|
final bool showIconWithLabel;
|
|
final ValueChanged<bool>? onMenuVisibilityChanged;
|
|
|
|
/// Hosts the Search/New/Bestie glass surfaces in one native container.
|
|
final bool mergingNavigation;
|
|
|
|
final List<({String label, String symbol, VoidCallback? onPressed})> actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final route = ModalRoute.of(context);
|
|
final coveringProgress = useAnimation(
|
|
route?.secondaryAnimation ?? kAlwaysDismissedAnimation,
|
|
);
|
|
final nativeControlsVisible =
|
|
(route?.isCurrent ?? true) && coveringProgress == 0;
|
|
|
|
final brightness = context.theme.brightness;
|
|
final foreground = context.colors.onSurface.toARGB32();
|
|
final avatarFuture = useMemoized(
|
|
() => avatarInitial == null
|
|
? null
|
|
: nativeAvatarImage(
|
|
url: avatarUrl,
|
|
initial: avatarInitial!,
|
|
circular: true,
|
|
background: context.colors.primaryContainer,
|
|
foreground: context.colors.onPrimaryContainer,
|
|
networkImage: NetworkImage.new,
|
|
),
|
|
[
|
|
avatarUrl,
|
|
avatarInitial,
|
|
context.colors.primaryContainer,
|
|
context.colors.onPrimaryContainer,
|
|
],
|
|
);
|
|
final avatar = useFuture(avatarFuture, preserveState: false).data;
|
|
final channel = useState<MethodChannel?>(null);
|
|
final latest = useRef(actions)..value = actions;
|
|
final latestVisibility = useRef(onMenuVisibilityChanged)
|
|
..value = onMenuVisibilityChanged;
|
|
final latestEnabled = useRef(enabled)..value = enabled;
|
|
final menu = [
|
|
for (var i = 0; i < actions.length; i++)
|
|
{
|
|
'id': i,
|
|
'dividerBefore': dividerBefore.contains(i),
|
|
'sectionTitle': ?sectionTitles[i],
|
|
'label': actions[i].label,
|
|
'symbol': actions[i].symbol,
|
|
'enabled': enabled && actions[i].onPressed != null,
|
|
'checked': i == selectedIndex,
|
|
},
|
|
];
|
|
final content = <String, Object>{
|
|
'icon': icon,
|
|
'imageBytes': ?avatar,
|
|
'label': ?label,
|
|
'labelFontSize': ?labelFontSize,
|
|
'showIconWithLabel': showIconWithLabel,
|
|
'accessibilityLabel': accessibilityLabel,
|
|
'menu': menu,
|
|
'deferMenuActions': deferMenuActions,
|
|
};
|
|
// Keep one handler per channel. Replacing this effect on enabled changes
|
|
// would let the old hook cleanup clear the newly installed handler.
|
|
useEffect(() {
|
|
final current = channel.value;
|
|
if (current == null) return null;
|
|
current.setMethodCallHandler((call) async {
|
|
if (call.method == 'menuVisibilityChanged') {
|
|
latestVisibility.value?.call(call.arguments as bool);
|
|
return;
|
|
}
|
|
if (call.method != 'menuAction') return;
|
|
final index = call.arguments as int;
|
|
if (index >= 0 && index < latest.value.length) {
|
|
if (latestEnabled.value) latest.value[index].onPressed?.call();
|
|
}
|
|
});
|
|
return () => current.setMethodCallHandler(null);
|
|
}, [channel.value]);
|
|
useEffect(
|
|
() {
|
|
final current = channel.value;
|
|
if (current != null) {
|
|
unawaited(current.invokeMethod<void>('setContent', content));
|
|
unawaited(
|
|
current.invokeMethod<void>('setAppearance', {
|
|
'brightness': brightness.name,
|
|
'foregroundColor': foreground,
|
|
'enabled': enabled,
|
|
}),
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
[
|
|
channel.value,
|
|
avatar,
|
|
icon,
|
|
label,
|
|
labelFontSize,
|
|
accessibilityLabel,
|
|
enabled,
|
|
selectedIndex,
|
|
deferMenuActions,
|
|
showIconWithLabel,
|
|
...dividerBefore,
|
|
...sectionTitles.entries.expand((e) => [e.key, e.value]),
|
|
...actions.expand((a) => [a.label, a.symbol, a.onPressed != null]),
|
|
brightness,
|
|
foreground,
|
|
],
|
|
);
|
|
// UIKit glass layers can float above the incoming Flutter route during
|
|
// its transition. Remove the covered route's platform view immediately.
|
|
if (!nativeControlsVisible) {
|
|
return SizedBox(width: width, height: height);
|
|
}
|
|
return SizedBox(
|
|
width: width,
|
|
height: height,
|
|
child: UiKitView(
|
|
viewType: 'buzz/navigation_glass',
|
|
gestureRecognizers: {
|
|
if (eagerGestures)
|
|
Factory<EagerGestureRecognizer>(EagerGestureRecognizer.new),
|
|
},
|
|
creationParams: {
|
|
...content,
|
|
'plain': plain,
|
|
'fillWidth': fillWidth,
|
|
'buttonCenterX': width / 2,
|
|
'controlWidth': mergingNavigation ? 112.0 : width,
|
|
'mergingNavigation': mergingNavigation,
|
|
'controlSize': height,
|
|
'hitTargetWidth': width,
|
|
'hitTargetHeight': height,
|
|
'brightness': brightness.name,
|
|
'foregroundColor': foreground,
|
|
'enabled': enabled,
|
|
},
|
|
creationParamsCodec: const StandardMessageCodec(),
|
|
onPlatformViewCreated: (id) =>
|
|
channel.value = MethodChannel('buzz/navigation_glass/$id'),
|
|
),
|
|
);
|
|
}
|
|
}
|