feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,738 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
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: context.l10n.cameraMenu,
|
||||
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 * 4) +
|
||||
(_attachmentMenuItemSpacing * 3);
|
||||
|
||||
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 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;
|
||||
|
||||
const _AttachmentSurfacePanel({
|
||||
super.key,
|
||||
required this.surface,
|
||||
required this.suggestionPanel,
|
||||
required this.onBack,
|
||||
required this.onCamera,
|
||||
required this.onPhotos,
|
||||
required this.onVideo,
|
||||
required this.onFiles,
|
||||
required this.onCapture,
|
||||
required this.onPickAllPhotos,
|
||||
required this.onChoosePhotos,
|
||||
required this.onChooseAllPhotos,
|
||||
});
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
_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,
|
||||
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, file }
|
||||
|
||||
@immutable
|
||||
class _PendingAttachment {
|
||||
static var _nextId = 0;
|
||||
|
||||
final int id;
|
||||
final XFile file;
|
||||
final _PendingAttachmentKind kind;
|
||||
final bool deleteAfterUse;
|
||||
|
||||
_PendingAttachment({
|
||||
required this.file,
|
||||
required this.kind,
|
||||
this.deleteAfterUse = false,
|
||||
}) : 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,
|
||||
void Function(List<XFile>, {bool deleteAfterUse}) queueImages,
|
||||
) async {
|
||||
final retained = await retainTemporaryImages(images);
|
||||
if (!context.mounted) {
|
||||
for (final image in retained) {
|
||||
await _deleteXFile(image);
|
||||
}
|
||||
return;
|
||||
}
|
||||
queueImages(retained, deleteAfterUse: true);
|
||||
}
|
||||
|
||||
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.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
|
||||
? context.l10n.closeFormatting
|
||||
: context.l10n.addAttachment,
|
||||
_AttachmentSurface.menu => context.l10n.closeAttachments,
|
||||
_AttachmentSurface.camera ||
|
||||
_AttachmentSurface.photos => context.l10n.backAttachmentOptions,
|
||||
},
|
||||
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 => LucideIcons.camera,
|
||||
_AttachmentSurface.photos => LucideIcons.images,
|
||||
_AttachmentSurface.closed ||
|
||||
_AttachmentSurface.menu => LucideIcons.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 onFiles;
|
||||
|
||||
const _AttachmentMenu({
|
||||
required this.layout,
|
||||
required this.onCamera,
|
||||
required this.onPhotos,
|
||||
required this.onVideo,
|
||||
required this.onFiles,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: 4,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: _attachmentMenuItemSpacing),
|
||||
itemBuilder: (context, index) {
|
||||
final (icon, id, label, onTap) = switch (index) {
|
||||
0 => (
|
||||
LucideIcons.camera,
|
||||
'camera',
|
||||
context.l10n.cameraMenu,
|
||||
onCamera,
|
||||
),
|
||||
1 => (
|
||||
LucideIcons.images,
|
||||
'photos',
|
||||
context.l10n.photosMenu,
|
||||
onPhotos,
|
||||
),
|
||||
2 => (
|
||||
LucideIcons.video,
|
||||
'video',
|
||||
context.l10n.videoMenu,
|
||||
onVideo,
|
||||
),
|
||||
_ => (
|
||||
LucideIcons.file,
|
||||
'files',
|
||||
context.l10n.filesMenu,
|
||||
onFiles,
|
||||
),
|
||||
};
|
||||
return _AttachmentMenuItem(
|
||||
height: layout.itemHeight,
|
||||
icon: icon,
|
||||
id: id,
|
||||
label: label,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentMenuItem extends StatelessWidget {
|
||||
final double height;
|
||||
final IconData icon;
|
||||
final String id;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AttachmentMenuItem({
|
||||
required this.height,
|
||||
required this.icon,
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
key: ValueKey('attachment-menu-item-$id'),
|
||||
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-$id'),
|
||||
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-$id',
|
||||
),
|
||||
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: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
|
||||
itemBuilder: (context, index) {
|
||||
final attachment = attachments[index];
|
||||
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(
|
||||
LucideIcons.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(
|
||||
LucideIcons.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(
|
||||
LucideIcons.file,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Grid.quarter),
|
||||
Text(
|
||||
attachment.file.name.isEmpty
|
||||
? context.l10n.fileAttachment
|
||||
: 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: context.l10n.removeAttachment,
|
||||
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(
|
||||
LucideIcons.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(LucideIcons.image, color: context.colors.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
return Image.memory(
|
||||
data,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user