Better attachment preview progress

This commit is contained in:
2026-01-11 13:40:35 +08:00
parent 826238a374
commit b7d5aa5dfb
4 changed files with 272 additions and 272 deletions

View File

@@ -325,6 +325,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
isCompact: true, isCompact: true,
item: attachments[idx], item: attachments[idx],
progress: progressMap[idx], progress: progressMap[idx],
isUploading: progressMap.containsKey(idx),
onRequestUpload: () async { onRequestUpload: () async {
final config = final config =
await showModalBottomSheet< await showModalBottomSheet<

View File

@@ -458,6 +458,10 @@ class ChatInput extends HookConsumerWidget {
item: attachments[idx], item: attachments[idx],
progress: progress:
attachmentProgress['chat-upload']?[idx], attachmentProgress['chat-upload']?[idx],
isUploading:
attachmentProgress['chat-upload']
?.containsKey(idx) ??
false,
onRequestUpload: () => onUploadAttachment(idx), onRequestUpload: () => onUploadAttachment(idx),
onDelete: () => onDeleteAttachment(idx), onDelete: () => onDeleteAttachment(idx),
onUpdate: (value) { onUpdate: (value) {

View File

@@ -14,13 +14,12 @@ import 'package:island/services/file_uploader.dart';
import 'package:island/utils/format.dart'; import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sensitive.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart'; import 'package:super_context_menu/super_context_menu.dart';
import 'sensitive.dart';
class SensitiveMarksSelector extends StatefulWidget { class SensitiveMarksSelector extends StatefulWidget {
final List<int> initial; final List<int> initial;
final ValueChanged<List<int>>? onChanged; final ValueChanged<List<int>>? onChanged;
@@ -85,6 +84,7 @@ class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
class AttachmentPreview extends HookConsumerWidget { class AttachmentPreview extends HookConsumerWidget {
final UniversalFile item; final UniversalFile item;
final double? progress; final double? progress;
final bool isUploading;
final Function(int)? onMove; final Function(int)? onMove;
final Function? onDelete; final Function? onDelete;
final Function? onInsert; final Function? onInsert;
@@ -96,6 +96,7 @@ class AttachmentPreview extends HookConsumerWidget {
super.key, super.key,
required this.item, required this.item,
this.progress, this.progress,
this.isUploading = false,
this.onRequestUpload, this.onRequestUpload,
this.onMove, this.onMove,
this.onDelete, this.onDelete,
@@ -125,79 +126,72 @@ class AttachmentPreview extends HookConsumerWidget {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, useRootNavigator: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( heightFactor: 0.6,
heightFactor: 0.6, titleText: 'rename'.tr(),
titleText: 'rename'.tr(), child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Padding(
Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
padding: const EdgeInsets.symmetric( child: TextField(
horizontal: 24, controller: nameController,
vertical: 24, decoration: InputDecoration(
), labelText: 'fileName'.tr(),
child: TextField( border: const OutlineInputBorder(),
controller: nameController, errorText: errorMessage,
decoration: InputDecoration(
labelText: 'fileName'.tr(),
border: const OutlineInputBorder(),
errorText: errorMessage,
),
),
), ),
Row( ),
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
final newName = nameController.text.trim();
if (newName.isEmpty) {
errorMessage = 'fieldCannotBeEmpty'.tr();
return;
}
if (item.isOnCloud) {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/drive/files/${item.data.id}/name',
data: jsonEncode(newName),
);
final newData = item.data;
newData.name = newName;
onUpdate?.call(
item.copyWith(
data: newData,
displayName: newName,
),
);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
} else {
// Local file rename
onUpdate?.call(item.copyWith(displayName: newName));
if (context.mounted) Navigator.pop(context);
}
},
child: Text('rename'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
), ),
), Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
final newName = nameController.text.trim();
if (newName.isEmpty) {
errorMessage = 'fieldCannotBeEmpty'.tr();
return;
}
if (item.isOnCloud) {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/drive/files/${item.data.id}/name',
data: jsonEncode(newName),
);
final newData = item.data;
newData.name = newName;
onUpdate?.call(
item.copyWith(data: newData, displayName: newName),
);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
} else {
// Local file rename
onUpdate?.call(item.copyWith(displayName: newName));
if (context.mounted) Navigator.pop(context);
}
},
child: Text('rename'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
); );
} }
@@ -205,91 +199,84 @@ class AttachmentPreview extends HookConsumerWidget {
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( heightFactor: 0.6,
heightFactor: 0.6, titleText: 'markAsSensitive'.tr(),
titleText: 'markAsSensitive'.tr(), child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Padding(
Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
padding: const EdgeInsets.symmetric( child: Column(
horizontal: 24, children: [
vertical: 24, // Sensitive categories checklist
SensitiveMarksSelector(
key: _sensitiveSelectorKey,
initial: (item.data.sensitiveMarks ?? [])
.map((e) => e as int)
.cast<int>()
.toList(),
onChanged: (marks) {
// Update local data immediately (optimistic)
final newData = item.data;
newData.sensitiveMarks = marks;
final updatedFile = item.copyWith(data: newData);
onUpdate?.call(item.copyWith(data: updatedFile));
},
), ),
child: Column( ],
children: [ ),
// Sensitive categories checklist
SensitiveMarksSelector(
key: _sensitiveSelectorKey,
initial:
(item.data.sensitiveMarks ?? [])
.map((e) => e as int)
.cast<int>()
.toList(),
onChanged: (marks) {
// Update local data immediately (optimistic)
final newData = item.data;
newData.sensitiveMarks = marks;
final updatedFile = item.copyWith(data: newData);
onUpdate?.call(item.copyWith(data: updatedFile));
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
// Use the current selections from stateful selector via GlobalKey
final selectorState =
_sensitiveSelectorKey.currentState;
final marks = selectorState?.current ?? <int>[];
await apiClient.put(
'/drive/files/${item.data.id}/marks',
data: jsonEncode({'sensitive_marks': marks}),
);
final newData = item.data as SnCloudFile;
final updatedFile = item.copyWith(
data: newData.copyWith(sensitiveMarks: marks),
);
onUpdate?.call(updatedFile);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
child: Text('confirm'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
), ),
), Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
// Use the current selections from stateful selector via GlobalKey
final selectorState = _sensitiveSelectorKey.currentState;
final marks = selectorState?.current ?? <int>[];
await apiClient.put(
'/drive/files/${item.data.id}/marks',
data: jsonEncode({'sensitive_marks': marks}),
);
final newData = item.data as SnCloudFile;
final updatedFile = item.copyWith(
data: newData.copyWith(sensitiveMarks: marks),
);
onUpdate?.call(updatedFile);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
child: Text('confirm'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
); );
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var ratio = var ratio = item.isOnCloud
item.isOnCloud ? (item.data.fileMeta?['ratio'] is num
? (item.data.fileMeta?['ratio'] is num ? item.data.fileMeta!['ratio'].toDouble()
? item.data.fileMeta!['ratio'].toDouble() : 1.0)
: 1.0) : 1.0;
: 1.0;
if (ratio == 0) ratio = 1.0; if (ratio == 0) ratio = 1.0;
final contentWidget = ClipRRect( final contentWidget = ClipRRect(
@@ -387,7 +374,7 @@ class AttachmentPreview extends HookConsumerWidget {
return Placeholder(); return Placeholder();
}, },
), ),
if (progress != null) if (isUploading && progress != null && (progress ?? 0) > 0)
Positioned.fill( Positioned.fill(
child: Container( child: Container(
color: Colors.black.withOpacity(0.3), color: Colors.black.withOpacity(0.3),
@@ -399,16 +386,10 @@ class AttachmentPreview extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (progress != null) Text(
Text( '${(progress! * 100).toStringAsFixed(2)}%',
'${(progress! * 100).toStringAsFixed(2)}%', style: TextStyle(color: Colors.white),
style: TextStyle(color: Colors.white), ),
)
else
Text(
'uploading'.tr(),
style: TextStyle(color: Colors.white),
),
Gap(6), Gap(6),
Center( Center(
child: LinearProgressIndicator(value: progress), child: LinearProgressIndicator(value: progress),
@@ -417,6 +398,28 @@ class AttachmentPreview extends HookConsumerWidget {
), ),
), ),
), ),
if (isUploading && (progress == null || progress == 0))
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(
horizontal: 40,
vertical: 16,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'processing'.tr(),
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(child: LinearProgressIndicator(value: null)),
],
),
),
),
], ],
), ),
).center(), ).center(),
@@ -497,8 +500,9 @@ class AttachmentPreview extends HookConsumerWidget {
if (onRequestUpload != null) if (onRequestUpload != null)
InkWell( InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: onTap: item.isOnCloud
item.isOnCloud ? null : () => onRequestUpload?.call(), ? null
: () => onRequestUpload?.call(),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
@@ -507,40 +511,39 @@ class AttachmentPreview extends HookConsumerWidget {
horizontal: 8, horizontal: 8,
vertical: 4, vertical: 4,
), ),
child: child: (item.isOnCloud)
(item.isOnCloud) ? Row(
? Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Icon(
Icon( Symbols.cloud,
Symbols.cloud, size: 16,
size: 16, color: Colors.white,
color: Colors.white, ),
if (!isCompact) const Gap(8),
if (!isCompact)
Text(
'attachmentOnCloud'.tr(),
style: TextStyle(color: Colors.white),
), ),
if (!isCompact) const Gap(8), ],
if (!isCompact) )
Text( : Row(
'attachmentOnCloud'.tr(), mainAxisSize: MainAxisSize.min,
style: TextStyle(color: Colors.white), children: [
), Icon(
], Symbols.cloud_off,
) size: 16,
: Row( color: Colors.white,
mainAxisSize: MainAxisSize.min, ),
children: [ if (!isCompact) const Gap(8),
Icon( if (!isCompact)
Symbols.cloud_off, Text(
size: 16, 'attachmentOnDevice'.tr(),
color: Colors.white, style: TextStyle(color: Colors.white),
), ),
if (!isCompact) const Gap(8), ],
if (!isCompact) ),
Text(
'attachmentOnDevice'.tr(),
style: TextStyle(color: Colors.white),
),
],
),
), ),
), ),
), ),
@@ -552,49 +555,48 @@ class AttachmentPreview extends HookConsumerWidget {
); );
return ContextMenuWidget( return ContextMenuWidget(
menuProvider: menuProvider: (MenuRequest request) => Menu(
(MenuRequest request) => Menu( children: [
children: [ if (item.isOnDevice && item.type == UniversalFileType.image)
if (item.isOnDevice && item.type == UniversalFileType.image) MenuAction(
MenuAction( title: 'crop'.tr(),
title: 'crop'.tr(), image: MenuImage.icon(Symbols.crop),
image: MenuImage.icon(Symbols.crop), callback: () async {
callback: () async { final result = await cropImage(
final result = await cropImage( context,
context, image: item.data,
image: item.data, replacePath: true,
replacePath: true, );
); if (result == null) return;
if (result == null) return; onUpdate?.call(item.copyWith(data: result));
onUpdate?.call(item.copyWith(data: result)); },
}, ),
), if (item.isOnDevice)
if (item.isOnDevice) MenuAction(
MenuAction( title: 'rename'.tr(),
title: 'rename'.tr(), image: MenuImage.icon(Symbols.edit),
image: MenuImage.icon(Symbols.edit), callback: () async {
callback: () async { await _showRenameSheet(context, ref);
await _showRenameSheet(context, ref); },
}, ),
), if (item.isOnCloud)
if (item.isOnCloud) MenuAction(
MenuAction( title: 'rename'.tr(),
title: 'rename'.tr(), image: MenuImage.icon(Symbols.edit),
image: MenuImage.icon(Symbols.edit), callback: () async {
callback: () async { await _showRenameSheet(context, ref);
await _showRenameSheet(context, ref); },
}, ),
), if (item.isOnCloud)
if (item.isOnCloud) MenuAction(
MenuAction( title: 'markAsSensitive'.tr(),
title: 'markAsSensitive'.tr(), image: MenuImage.icon(Symbols.no_adult_content),
image: MenuImage.icon(Symbols.no_adult_content), callback: () async {
callback: () async { await _showSensitiveDialog(context, ref);
await _showSensitiveDialog(context, ref); },
}, ),
), ],
], ),
),
child: contentWidget, child: contentWidget,
); );
} }

View File

@@ -71,14 +71,14 @@ class ComposeAttachments extends ConsumerWidget {
isCompact: isCompact, isCompact: isCompact,
item: state.attachments.value[idx], item: state.attachments.value[idx],
progress: progressMap[idx], progress: progressMap[idx],
isUploading: progressMap.containsKey(idx),
onRequestUpload: () async { onRequestUpload: () async {
final config = await showModalBottomSheet<AttachmentUploadConfig>( final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: ref.context, context: ref.context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, useRootNavigator: true,
builder: builder: (context) =>
(context) => AttachmentUploaderSheet(ref: ref, state: state, index: idx),
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
); );
if (config != null) { if (config != null) {
await ComposeLogic.uploadAttachment( await ComposeLogic.uploadAttachment(
@@ -146,19 +146,21 @@ class ArticleComposeAttachments extends ConsumerWidget {
isCompact: true, isCompact: true,
item: attachments[idx], item: attachments[idx],
progress: progressMap[idx], progress: progressMap[idx],
isUploading: progressMap.containsKey(idx),
onRequestUpload: () async { onRequestUpload: () async {
final config = await showModalBottomSheet< final config =
AttachmentUploadConfig await showModalBottomSheet<
>( AttachmentUploadConfig
context: context, >(
isScrollControlled: true, context: context,
builder: isScrollControlled: true,
(context) => AttachmentUploaderSheet( builder: (context) =>
ref: ref, AttachmentUploaderSheet(
state: state, ref: ref,
index: idx, state: state,
), index: idx,
); ),
);
if (config != null) { if (config != null) {
await ComposeLogic.uploadAttachment( await ComposeLogic.uploadAttachment(
ref, ref,
@@ -168,24 +170,15 @@ class ArticleComposeAttachments extends ConsumerWidget {
); );
} }
}, },
onUpdate: onUpdate: (value) => ComposeLogic.updateAttachment(
(value) => ComposeLogic.updateAttachment( state,
state, value,
value, idx,
idx, ),
), onDelete: () =>
onDelete: ComposeLogic.deleteAttachment(ref, state, idx),
() => ComposeLogic.deleteAttachment( onInsert: () =>
ref, ComposeLogic.insertAttachment(ref, state, idx),
state,
idx,
),
onInsert:
() => ComposeLogic.insertAttachment(
ref,
state,
idx,
),
), ),
), ),
], ],