9dfa06ffee
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>
739 lines
23 KiB
Dart
739 lines
23 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|