♻️ Refactored attachment cache

This commit is contained in:
2024-11-18 00:55:39 +08:00
parent 432705c570
commit 359cd94532
16 changed files with 712 additions and 213 deletions

View File

@ -14,7 +14,7 @@ import 'package:surface/widgets/universal_image.dart';
import 'package:uuid/uuid.dart';
class AttachmentItem extends StatelessWidget {
final SnAttachment data;
final SnAttachment? data;
final bool isExpandable;
const AttachmentItem({
super.key,
@ -23,15 +23,19 @@ class AttachmentItem extends StatelessWidget {
});
Widget _buildContent(BuildContext context, String heroTag) {
final tp = data.mimetype.split('/').firstOrNull;
if (data == null) {
return const Icon(Symbols.cancel).center();
}
final tp = data!.mimetype.split('/').firstOrNull;
final sn = context.read<SnNetworkProvider>();
switch (tp) {
case 'image':
return Hero(
tag: 'attachment-${data.rid}-$heroTag',
tag: 'attachment-${data!.rid}-$heroTag',
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data.rid),
key: Key('attachment-${data.rid}-$heroTag'),
sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$heroTag'),
fit: BoxFit.cover,
),
);
@ -45,7 +49,7 @@ class AttachmentItem extends StatelessWidget {
final uuid = Uuid();
final heroTag = uuid.v4();
if (data.isMature) {
if (data!.isMature) {
return _AttachmentItemSensitiveBlur(
child: _buildContent(context, heroTag),
);
@ -56,7 +60,7 @@ class AttachmentItem extends StatelessWidget {
child: _buildContent(context, heroTag),
onTap: () {
context.pushTransparentRoute(
AttachmentDetailPopup(data: data, heroTag: heroTag),
AttachmentDetailPopup(data: data!, heroTag: heroTag),
rootNavigator: true,
);
},

View File

@ -6,7 +6,7 @@ import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_item.dart';
class AttachmentList extends StatelessWidget {
final List<SnAttachment> data;
final List<SnAttachment?> data;
final bool? bordered;
final double? maxHeight;
final EdgeInsets? listPadding;
@ -46,7 +46,7 @@ class AttachmentList extends StatelessWidget {
borderRadius: kDefaultRadius,
),
child: AspectRatio(
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
aspectRatio: data[0]?.metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius,
child: AttachmentItem(data: data[0], isExpandable: true),
@ -62,7 +62,7 @@ class AttachmentList extends StatelessWidget {
border: Border(top: borderSide, bottom: borderSide),
),
child: AspectRatio(
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
aspectRatio: data[0]?.metadata['ratio']?.toDouble() ?? 1,
child: AttachmentItem(data: data[0], isExpandable: true),
),
);
@ -86,7 +86,7 @@ class AttachmentList extends StatelessWidget {
borderRadius: kDefaultRadius,
),
child: AspectRatio(
aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1,
aspectRatio: data[idx]?.metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius,
child:

View File

@ -6,6 +6,7 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/markdown_content.dart';
class ChatMessage extends StatelessWidget {
@ -63,6 +64,13 @@ class ChatMessage extends StatelessWidget {
content: data.body['text'],
isAutoWarp: true,
),
if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList(
data: data.preload!.attachments!,
bordered: true,
maxHeight: 520,
listPadding: const EdgeInsets.symmetric(horizontal: 12),
),
if (!hasMerged) const Gap(8),
],
),

View File

@ -1,9 +1,15 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart';
class ChatMessageInput extends StatefulWidget {
final ChatMessageController controller;
@ -14,15 +20,83 @@ class ChatMessageInput extends StatefulWidget {
}
class _ChatMessageInputState extends State<ChatMessageInput> {
bool _isBusy = false;
double? _progress;
final TextEditingController _contentController = TextEditingController();
final FocusNode _focusNode = FocusNode();
void _sendMessage() {
Future<void> _sendMessage() async {
if (_isBusy) return;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
try {
for (int i = 0; i < _attachments.length; i++) {
final media = _attachments[i];
if (media.attachment != null) continue; // Already uploaded, skip
if (media.isEmpty) continue; // Nothing to do, skip
final place = await attach.chunkedUploadInitialize(
(await media.length())!,
media.name,
'interactive',
null,
);
final item = await attach.chunkedUploadParts(
media.toFile()!,
place.$1,
place.$2,
onProgress: (progress) {
// Calculate overall progress for attachments
setState(() {
progress = (i + progress) / _attachments.length;
});
},
);
_attachments[i] = PostWriteMedia(item);
}
} catch (err) {
if (!mounted) return;
setState(() => _isBusy = false);
context.showErrorDialog(err);
return;
}
attach.putCache(
_attachments.where((e) => e.attachment != null).map((e) => e.attachment!),
);
// Send the message
// NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type
widget.controller.sendMessage(
'messages.new',
_contentController.text,
attachments: _attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
);
_contentController.clear();
_attachments.clear();
setState(() => _isBusy = false);
}
final List<PostWriteMedia> _attachments = List.empty(growable: true);
final _imagePicker = ImagePicker();
void _selectMedia() async {
final result = await _imagePicker.pickMultipleMedia();
if (result.isEmpty) return;
_attachments.addAll(
result.map((e) => PostWriteMedia.fromFile(e)),
);
setState(() {});
}
@override
@ -37,6 +111,33 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isBusy && _progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
Padding(
padding: _attachments.isNotEmpty
? const EdgeInsets.only(top: 8)
: EdgeInsets.zero,
child: PostMediaPendingListRaw(
attachments: _attachments,
isBusy: _isBusy,
onUpdate: (idx, updatedMedia) async {
setState(() => _attachments[idx] = updatedMedia);
},
onRemove: (idx) async {
setState(() => _attachments.removeAt(idx));
},
onUpdateBusy: (state) => setState(() => _isBusy = state),
).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.fastEaseInToSlowEaseOut),
),
SizedBox(
height: 56,
child: Row(
@ -53,6 +154,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
border: InputBorder.none,
),
onSubmitted: (_) {
if (_isBusy) return;
_sendMessage();
_focusNode.requestFocus();
},
@ -60,7 +162,14 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
),
const Gap(8),
IconButton(
onPressed: _sendMessage,
onPressed: _isBusy ? null : _selectMedia,
icon: Icon(
Symbols.add_photo_alternate,
color: Theme.of(context).colorScheme.primary,
),
),
IconButton(
onPressed: _isBusy ? null : _sendMessage,
icon: Icon(
Symbols.send,
color: Theme.of(context).colorScheme.primary,

View File

@ -61,7 +61,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
preload: SnPostPreload(
attachments: attachments
.where(
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
(ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false,
)
.toList(),
),

View File

@ -18,54 +18,22 @@ import 'package:surface/widgets/dialog.dart';
class PostMediaPendingList extends StatelessWidget {
final PostWriteController controller;
const PostMediaPendingList({super.key, required this.controller});
void _cropImage(BuildContext context, int idx) async {
final media = controller.attachments[idx];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
// ignore: use_build_context_synchronously
imageProvider: media.getImageProvider(context)!,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
// ignore: use_build_context_synchronously
imageProvider: media.getImageProvider(context)!,
);
if (result == null) return;
if (!context.mounted) return;
Future<void> _handleUpdate(int idx, PostWriteMedia updatedMedia) async {
controller.setIsBusy(true);
final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
controller.setAttachmentAt(
idx,
PostWriteMedia.fromBytes(rawBytes, media.name, media.type),
);
controller.setIsBusy(false);
try {
controller.setAttachmentAt(idx, updatedMedia);
} finally {
controller.setIsBusy(false);
}
}
void _deleteAttachment(BuildContext context, int idx) async {
final media = controller.attachments[idx];
if (media.attachment == null) return;
Future<void> _handleRemove(int idx) async {
controller.setIsBusy(true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}');
controller.removeAttachmentAt(idx);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
} finally {
controller.setIsBusy(false);
}
@ -73,108 +41,180 @@ class PostMediaPendingList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Container(
constraints: const BoxConstraints(maxHeight: 120),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
separatorBuilder: (context, index) => const Gap(8),
itemCount: controller.attachments.length,
itemBuilder: (context, idx) {
final media = controller.attachments[idx];
return ContextMenuRegion(
contextMenu: ContextMenu(
entries: [
if (media.type == PostWriteMediaType.image &&
media.attachment != null)
MenuItem(
label: 'preview'.tr(),
icon: Symbols.preview,
onSelected: () {
context.pushTransparentRoute(
AttachmentDetailPopup(data: media.attachment!),
rootNavigator: true,
);
},
),
if (media.type == PostWriteMediaType.image &&
media.attachment == null)
MenuItem(
label: 'crop'.tr(),
icon: Symbols.crop,
onSelected: () => _cropImage(context, idx),
),
if (media.attachment != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: controller.isBusy
? null
: () => _deleteAttachment(context, idx),
),
if (media.attachment == null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () {
controller.removeAttachmentAt(idx);
},
)
else
MenuItem(
label: 'unlink'.tr(),
icon: Symbols.link_off,
onSelected: () {
controller.removeAttachmentAt(idx);
},
),
],
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 1,
child: switch (media.type) {
PostWriteMediaType.image =>
LayoutBuilder(builder: (context, constraints) {
return Image(
image: media.getImageProvider(
context,
width: (constraints.maxWidth * devicePixelRatio)
.round(),
height:
(constraints.maxHeight * devicePixelRatio)
.round(),
)!,
fit: BoxFit.cover,
);
}),
_ => Container(
color: Theme.of(context).colorScheme.surface,
child: const Icon(Symbols.docs).center(),
),
},
),
),
),
);
},
),
return PostMediaPendingListRaw(
attachments: controller.attachments,
isBusy: controller.isBusy,
onUpdate: (idx, updatedMedia) => _handleUpdate(idx, updatedMedia),
onRemove: (idx) => _handleRemove(idx),
onUpdateBusy: (state) => controller.setIsBusy(state),
);
},
);
}
}
class PostMediaPendingListRaw extends StatelessWidget {
final List<PostWriteMedia> attachments;
final bool isBusy;
final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate;
final Future<void> Function(int idx)? onRemove;
final void Function(bool state)? onUpdateBusy;
const PostMediaPendingListRaw({
super.key,
required this.attachments,
required this.isBusy,
this.onUpdate,
this.onRemove,
this.onUpdateBusy,
});
Future<void> _cropImage(BuildContext context, int idx) async {
final media = attachments[idx];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
context,
imageProvider: media.getImageProvider(context)!,
)
: await showMaterialImageCropper(
context,
imageProvider: media.getImageProvider(context)!,
);
if (result == null) return;
final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
if (onUpdate != null) {
final updatedMedia = PostWriteMedia.fromBytes(
rawBytes,
media.name,
media.type,
);
await onUpdate!(idx, updatedMedia);
}
}
Future<void> _deleteAttachment(BuildContext context, int idx) async {
final media = attachments[idx];
if (media.attachment == null) return;
try {
onUpdateBusy?.call(true);
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}');
onRemove!(idx);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
} finally {
onUpdateBusy?.call(false);
}
}
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
constraints: const BoxConstraints(maxHeight: 120),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
separatorBuilder: (context, index) => const Gap(8),
itemCount: attachments.length,
itemBuilder: (context, idx) {
final media = attachments[idx];
return ContextMenuRegion(
contextMenu: ContextMenu(
entries: [
if (media.type == PostWriteMediaType.image &&
media.attachment != null)
MenuItem(
label: 'preview'.tr(),
icon: Symbols.preview,
onSelected: () {
context.pushTransparentRoute(
AttachmentDetailPopup(data: media.attachment!),
rootNavigator: true,
);
},
),
if (media.type == PostWriteMediaType.image &&
media.attachment == null)
MenuItem(
label: 'crop'.tr(),
icon: Symbols.crop,
onSelected: () => _cropImage(context, idx),
),
if (media.attachment != null && onRemove != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected:
isBusy ? null : () => _deleteAttachment(context, idx),
),
if (media.attachment == null && onRemove != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () {
onRemove!(idx);
},
)
else if (onRemove != null)
MenuItem(
label: 'unlink'.tr(),
icon: Symbols.link_off,
onSelected: () {
onRemove!(idx);
},
),
],
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 1,
child: switch (media.type) {
PostWriteMediaType.image =>
LayoutBuilder(builder: (context, constraints) {
return Image(
image: media.getImageProvider(
context,
width: (constraints.maxWidth * devicePixelRatio)
.round(),
height: (constraints.maxHeight * devicePixelRatio)
.round(),
)!,
fit: BoxFit.cover,
);
}),
_ => Container(
color: Theme.of(context).colorScheme.surface,
child: const Icon(Symbols.docs).center(),
),
},
),
),
),
);
},
),
);
}
}