Files
buzz/mobile/lib/features/channels/compose_bar/attachments.dart
klopez4212 634f48a8bd Mobile: open Solo chats with Bestie and workspace controls (#8884)
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 |
| --- | --- | --- |
| ![New chat
composer](https://github.com/user-attachments/assets/f9ea2a16-342e-4cea-a436-0777ff5e37aa)
| ![Checked workspace
popover](https://github.com/user-attachments/assets/80eb7a71-e424-4188-86d7-eb6292b148bf)
| ![Content-sized agent
sheet](https://github.com/user-attachments/assets/be608383-0188-4339-b56b-924085137452)
|

Empty workspace widget preview (unchanged):

![Compact empty workspace
message](https://github.com/user-attachments/assets/387c0729-dea3-4250-bb54-afc4e61b54a7)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-01 21:40:30 +00:00

829 lines
26 KiB
Dart

part of '../compose_bar.dart';
bool _rejectsNonVoiceAttachment(
_ComposerVoiceNote voiceNote,
List<_PendingAttachment> attachments,
ValueNotifier<String?> uploadError,
) {
if (!voiceNote.isPreparing &&
voiceNote.recorder == null &&
!attachments.any(
(attachment) => attachment.kind == _PendingAttachmentKind.voiceNote,
)) {
return false;
}
uploadError.value = voiceNote.recorder != null || voiceNote.isPreparing
? 'Finish or discard the voice note before attaching a file.'
: 'A voice note must be the only attachment.';
return true;
}
bool _queueComposerAttachment(
XFile file,
_PendingAttachmentKind kind,
ObjectRef<_ComposerVoiceNote> voiceNote,
ValueNotifier<List<_PendingAttachment>> attachments,
ValueNotifier<String?> uploadError,
ObjectRef<int> draftRevision, {
bool deleteAfterUse = false,
}) {
if (kind != _PendingAttachmentKind.voiceNote &&
_rejectsNonVoiceAttachment(
voiceNote.value,
attachments.value,
uploadError,
)) {
return false;
}
draftRevision.value += 1;
uploadError.value = null;
attachments.value = [
...attachments.value,
_PendingAttachment(file: file, kind: kind, deleteAfterUse: deleteAfterUse),
];
return true;
}
bool _queueComposerImages(
List<XFile> images,
_ComposerVoiceNote voiceNote,
ValueNotifier<List<_PendingAttachment>> attachments,
ValueNotifier<String?> uploadError,
ObjectRef<int> draftRevision,
bool deleteAfterUse,
) {
if (images.isEmpty ||
_rejectsNonVoiceAttachment(voiceNote, attachments.value, uploadError)) {
return false;
}
draftRevision.value += 1;
uploadError.value = null;
attachments.value = [
...attachments.value,
for (final image in images)
_PendingAttachment(
file: image,
kind: _PendingAttachmentKind.image,
deleteAfterUse: deleteAfterUse,
),
];
return true;
}
enum _AttachmentSurface { closed, menu, camera, photos }
const _attachmentMenuWidth = 216.0;
const _attachmentMenuPadding = Grid.xs;
const _attachmentMenuItemHeight = 52.0;
const _attachmentMenuItemSpacing = Grid.xxs;
const _attachmentMenuIconSize = 24.0;
const _attachmentMenuIconSlotWidth = 28.0;
const _attachmentExpandedHeight = 372.0;
@immutable
class _AttachmentMenuLayout {
final double itemHeight;
final double contentHeight;
final double height;
const _AttachmentMenuLayout({
required this.itemHeight,
required this.contentHeight,
required this.height,
});
factory _AttachmentMenuLayout.from(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(text: 'Camera', style: context.textTheme.titleMedium),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final itemHeight = math.max(
_attachmentMenuItemHeight,
textPainter.height + (Grid.xxs * 2),
);
textPainter.dispose();
final contentHeight =
(_attachmentMenuPadding * 2) +
(itemHeight * 5) +
(_attachmentMenuItemSpacing * 4);
return _AttachmentMenuLayout(
itemHeight: itemHeight,
contentHeight: contentHeight,
height: math.min(contentHeight, _attachmentExpandedHeight),
);
}
bool get isScrollable => contentHeight > height;
}
class _AttachmentSurfacePanel extends HookWidget {
final _AttachmentSurface surface;
final Widget suggestionPanel;
final VoidCallback onBack;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onVoiceNote;
final VoidCallback onFiles;
final Future<void> Function(XFile image) onCapture;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
final Future<void> Function(Future<void> Function()) preparePhotos;
const _AttachmentSurfacePanel({
super.key,
required this.surface,
required this.suggestionPanel,
required this.onBack,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onVoiceNote,
required this.onFiles,
required this.onCapture,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
required this.preparePhotos,
});
@override
Widget build(BuildContext context) {
if (surface == _AttachmentSurface.closed) return suggestionPanel;
final menuLayout = _AttachmentMenuLayout.from(context);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final isExpanded =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final morphController = useAnimationController(
initialValue: isExpanded ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 320),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 250),
);
final rawProgress = useAnimation(morphController);
final renderedExpandedSurface = useState<_AttachmentSurface?>(
isExpanded ? surface : null,
);
final latestSurface = useRef(surface);
latestSurface.value = surface;
useEffect(() {
void disposeCollapsedContent(AnimationStatus status) {
if (status == AnimationStatus.dismissed &&
latestSurface.value == _AttachmentSurface.menu) {
renderedExpandedSurface.value = null;
}
}
morphController.addStatusListener(disposeCollapsedContent);
return () =>
morphController.removeStatusListener(disposeCollapsedContent);
}, [morphController]);
useEffect(() {
if (isExpanded) {
renderedExpandedSurface.value = surface;
morphController.forward();
} else {
morphController.reverse();
}
return null;
}, [isExpanded, morphController, surface]);
final visibleExpandedSurface =
renderedExpandedSurface.value ?? (isExpanded ? surface : null);
final cameraInitializationReady =
reducedMotion ||
(surface == _AttachmentSurface.camera && rawProgress >= 1);
final expandedContent = switch (visibleExpandedSurface) {
_AttachmentSurface.camera => KeyedSubtree(
key: const ValueKey('camera-preview'),
child: KeyedSubtree(
key: ValueKey(
cameraInitializationReady
? 'camera-initialization-ready'
: 'camera-initialization-deferred',
),
child: _InlineCameraPreview(
initializeCamera: cameraInitializationReady,
onClose: onBack,
onCapture: onCapture,
),
),
),
_AttachmentSurface.photos => KeyedSubtree(
key: const ValueKey('photo-gallery'),
child: _PhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
preparePhotos: preparePhotos,
),
),
_AttachmentSurface.closed ||
_AttachmentSurface.menu ||
null => const SizedBox.shrink(),
};
double interval(double value, double begin, double end) {
return ((value - begin) / (end - begin)).clamp(0.0, 1.0);
}
final menuOpacity = 1 - interval(rawProgress, 0.12, 0.38);
final expandedOpacity = interval(rawProgress, 0.28, 0.65);
final sizeProgress = morphController.status == AnimationStatus.reverse
? const Cubic(0.22, 1, 0.36, 1).transform(rawProgress)
: Curves.easeInOutCubic.transform(rawProgress);
return LayoutBuilder(
builder: (context, constraints) {
final expandedWidth = constraints.maxWidth;
const expandedHeight = _attachmentExpandedHeight;
final width =
_attachmentMenuWidth +
((expandedWidth - _attachmentMenuWidth) * sizeProgress);
final height =
menuLayout.height +
((expandedHeight - menuLayout.height) * sizeProgress);
final baseColor = appPopoverColor(context);
final expandedColor =
visibleExpandedSurface == _AttachmentSurface.camera
? Colors.black
: baseColor;
return Align(
alignment: Alignment.topLeft,
heightFactor: 1,
child: SizedBox(
width: width,
height: height,
child: Material(
key: const ValueKey('attachment-surface-popover'),
type: MaterialType.card,
color: Color.lerp(baseColor, expandedColor, sizeProgress),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.antiAlias,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: 0,
top: 0,
width: _attachmentMenuWidth,
height: menuLayout.height,
child: IgnorePointer(
ignoring: surface != _AttachmentSurface.menu,
child: Opacity(
opacity: menuOpacity,
child: _AttachmentMenu(
layout: menuLayout,
onCamera: onCamera,
onPhotos: onPhotos,
onVideo: onVideo,
onVoiceNote: onVoiceNote,
onFiles: onFiles,
),
),
),
),
Positioned(
left: 0,
top: 0,
width: expandedWidth,
height: expandedHeight,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expandedOpacity,
child: expandedContent,
),
),
),
],
),
),
),
);
},
);
}
}
@immutable
class _ComposeDraftPayload {
final String content;
final List<List<String>> mediaTags;
const _ComposeDraftPayload({required this.content, required this.mediaTags});
factory _ComposeDraftPayload.fromDraft({
required String text,
required List<BlobDescriptor> attachments,
required List<CustomEmoji> customEmoji,
}) {
var content = text;
final mediaTags = <List<String>>[];
for (final attachment in attachments) {
mediaTags.add(attachment.toImetaTag());
content += '\n${attachment.toMarkdownImage()}';
}
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
}
}
enum _PendingAttachmentKind { image, video, voiceNote, file }
@immutable
class _PendingAttachment {
static var _nextId = 0;
final int id;
final XFile file;
final _PendingAttachmentKind kind;
final bool deleteAfterUse;
final Duration? duration;
final List<double> waveform;
_PendingAttachment({
required this.file,
required this.kind,
this.deleteAfterUse = false,
this.duration,
this.waveform = const [],
}) : id = _nextId++;
}
Future<void> _deleteXFile(XFile file) async {
final path = file.path;
if (path.isEmpty) return;
try {
await File(path).delete();
} on FileSystemException {
// Temporary files may already have been removed by the operating system.
}
}
Future<void> _deleteOwnedAttachments(
Iterable<_PendingAttachment> attachments,
) async {
for (final attachment in attachments) {
if (attachment.deleteAfterUse) await _deleteXFile(attachment.file);
}
}
void _useOwnedAttachmentCleanup(
ValueNotifier<List<_PendingAttachment>> attachments,
) {
useEffect(
() =>
() => unawaited(_deleteOwnedAttachments(attachments.value)),
[attachments],
);
}
void _removePendingAttachment(
ValueNotifier<List<_PendingAttachment>> attachments,
ObjectRef<int> draftRevision,
int id,
) {
draftRevision.value += 1;
final removed = attachments.value.where((attachment) => attachment.id == id);
attachments.value = _withoutAttachment(attachments.value, id);
unawaited(_deleteOwnedAttachments(removed));
}
Future<void> _retainAndQueueImages(
BuildContext context,
List<XFile> images,
bool Function(List<XFile>, {bool deleteAfterUse}) queueImages,
) async {
final retained = await retainTemporaryImages(images);
if (!context.mounted || !queueImages(retained, deleteAfterUse: true)) {
for (final image in retained) {
await _deleteXFile(image);
}
}
}
Future<BlobDescriptor> _uploadPendingAttachment(
MediaUploadService service,
_PendingAttachment attachment, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) => switch (attachment.kind) {
_PendingAttachmentKind.image => service.uploadImage(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.video => service.uploadVideo(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.voiceNote => service.uploadVoiceNote(
attachment.file,
duration: attachment.duration ?? Duration.zero,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.file => service.uploadFile(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
};
class _AttachmentTrigger extends StatelessWidget {
final _AttachmentSurface surface;
final bool formattingOpen;
final ValueChanged<BuildContext> onTap;
const _AttachmentTrigger({
required this.surface,
required this.formattingOpen,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 240);
return SizedBox.square(
dimension: 36,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
shape: BoxShape.circle,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: IconButton(
tooltip: switch (surface) {
_AttachmentSurface.closed =>
formattingOpen ? 'Close formatting' : 'Add attachment',
_AttachmentSurface.menu => 'Close attachments',
_AttachmentSurface.camera ||
_AttachmentSurface.photos => 'Back to attachment options',
},
onPressed: () => _runComposerAction(() => onTap(context)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: AnimatedRotation(
duration: duration,
curve: Curves.easeOutBack,
turns: surface == _AttachmentSurface.menu || formattingOpen
? 0.125
: 0,
child: AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(animation),
child: child,
),
),
child: Icon(
switch (surface) {
_AttachmentSurface.camera => BuzzIcons.camera,
_AttachmentSurface.photos => BuzzIcons.images,
_AttachmentSurface.closed ||
_AttachmentSurface.menu => BuzzIcons.plus,
},
key: ValueKey('attachment-trigger-${surface.name}'),
size: 20,
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}
}
class _AttachmentMenu extends StatelessWidget {
final _AttachmentMenuLayout layout;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onVoiceNote;
final VoidCallback onFiles;
const _AttachmentMenu({
required this.layout,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onVoiceNote,
required this.onFiles,
});
@override
Widget build(BuildContext context) {
final items = <(IconData, String, VoidCallback)>[
(BuzzIcons.camera, 'Camera', onCamera),
(BuzzIcons.images, 'Photos', onPhotos),
(BuzzIcons.video, 'Video', onVideo),
(BuzzIcons.mic, 'Voice note', onVoiceNote),
(BuzzIcons.file, 'Files', onFiles),
];
return SizedBox(
key: const ValueKey('attachment-menu'),
width: _attachmentMenuWidth,
height: layout.height,
child: ListView.separated(
key: const ValueKey('attachment-menu-scroll'),
padding: const EdgeInsets.all(_attachmentMenuPadding),
physics: layout.isScrollable
? null
: const NeverScrollableScrollPhysics(),
itemCount: items.length,
separatorBuilder: (_, _) =>
const SizedBox(height: _attachmentMenuItemSpacing),
itemBuilder: (context, index) {
final (icon, label, onTap) = items[index];
return _AttachmentMenuItem(
height: layout.itemHeight,
icon: icon,
label: label,
onTap: onTap,
);
},
),
);
}
}
class _AttachmentMenuItem extends StatelessWidget {
final double height;
final IconData icon;
final String label;
final VoidCallback onTap;
const _AttachmentMenuItem({
required this.height,
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
key: ValueKey('attachment-menu-item-${label.toLowerCase()}'),
height: height,
child: Tooltip(
message: label,
child: InkWell(
onTap: () => _runComposerAction(onTap),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
children: [
SizedBox(
key: ValueKey('attachment-menu-icon-${label.toLowerCase()}'),
width: _attachmentMenuIconSlotWidth,
child: Center(
child: Icon(
icon,
size: _attachmentMenuIconSize,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
label,
key: ValueKey(
'attachment-menu-label-${label.toLowerCase()}',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w400,
),
),
),
],
),
),
),
),
);
}
}
List<_PendingAttachment> _withoutAttachment(
List<_PendingAttachment> attachments,
int id,
) {
return [
for (final attachment in attachments)
if (attachment.id != id) attachment,
];
}
class _AttachmentStrip extends StatelessWidget {
final List<_PendingAttachment> attachments;
final ValueChanged<int> onRemove;
const _AttachmentStrip({required this.attachments, required this.onRemove});
@override
Widget build(BuildContext context) {
final thumbWidth = 72.0;
final thumbHeight = 72.0;
return SizedBox(
height: thumbHeight,
child: LayoutBuilder(
builder: (context, constraints) => ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: attachments.length,
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
itemBuilder: (context, index) {
final attachment = attachments[index];
if (attachment.kind == _PendingAttachmentKind.voiceNote) {
return SizedBox(
width: constraints.maxWidth,
child: VoiceNoteAttachment.local(
path: attachment.file.path,
duration: attachment.duration ?? Duration.zero,
waveform: attachment.waveform,
onRemove: () =>
_runComposerAction(() => onRemove(attachment.id)),
),
);
}
return Container(
key: ValueKey('compose-attachment:${attachment.id}'),
width: thumbWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: attachment.kind == _PendingAttachmentKind.video
? ColoredBox(
color: Colors.black,
child: Center(
child: Icon(
BuzzIcons.video,
color: Colors.white,
size: 24,
),
),
)
: attachment.kind == _PendingAttachmentKind.image &&
attachment.file.path.isNotEmpty
? Image.file(
File(attachment.file.path),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
BuzzIcons.image,
color: context.colors.onSurfaceVariant,
),
),
)
: attachment.kind == _PendingAttachmentKind.image
? _MemoryAttachmentImage(file: attachment.file)
: ColoredBox(
color: context.colors.surface,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
BuzzIcons.file,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.quarter),
Text(
attachment.file.name.isEmpty
? 'File'
: attachment.file.name,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
Positioned(
top: Grid.quarter,
right: Grid.quarter,
child: SizedBox(
width: 24,
height: 24,
child: IconButton(
onPressed: () =>
_runComposerAction(() => onRemove(attachment.id)),
tooltip: 'Remove attachment',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface.withValues(
alpha: 0.92,
),
minimumSize: const Size(24, 24),
maximumSize: const Size(24, 24),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
BuzzIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
);
},
),
),
);
}
}
class _MemoryAttachmentImage extends HookConsumerWidget {
final XFile file;
const _MemoryAttachmentImage({required this.file});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bytes = useFuture(useMemoized(() => file.readAsBytes(), [file]));
final data = bytes.data;
if (data == null || data.isEmpty) {
return ColoredBox(
color: context.colors.surface,
child: Icon(BuzzIcons.image, color: context.colors.onSurfaceVariant),
);
}
return Image.memory(
data,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(BuzzIcons.image, color: context.colors.onSurfaceVariant),
),
);
}
}
// Keep a new-chat draft mounted until a selected attachment has been retained.
Future<T> _prepareComposerAttachment<T>(
BuildContext context,
ValueNotifier<int> pending,
Future<T> Function() action,
) async {
pending.value++;
try {
return await action();
} finally {
if (context.mounted) pending.value--;
}
}