Upload tasks overlay

This commit is contained in:
2025-11-10 01:11:43 +08:00
parent 1395d65b76
commit 8a291c80b7
18 changed files with 582 additions and 196 deletions

View File

@@ -401,7 +401,7 @@ class AttachmentPreview extends HookConsumerWidget {
children: [
if (progress != null)
Text(
'${progress!.toStringAsFixed(2)}%',
'${(progress! * 100).toStringAsFixed(2)}%',
style: TextStyle(color: Colors.white),
)
else

View File

@@ -6,7 +6,6 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/attachment_preview.dart';
@@ -61,7 +60,7 @@ class CloudFilePicker extends HookConsumerWidget {
final cloudFile =
await FileUploader.createCloudFile(
fileData: file,
client: ref.read(apiClientProvider),
ref: ref,
onProgress: (progress, _) {
uploadProgress.value = progress;
},

View File

@@ -180,7 +180,7 @@ class ComposeLogic {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: attachment,
).future;
if (cloudFile != null) {
@@ -510,7 +510,7 @@ class ComposeLogic {
cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: attachment,
poolId: poolId ?? selectedPoolId,
mode:

View File

@@ -241,7 +241,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
final file = universalFiles[idx];
final cloudFile =
await FileUploader.createCloudFile(
client: apiClient,
ref: ref,
fileData: file,
onProgress: (progress, _) {
if (mounted) {

View File

@@ -18,81 +18,279 @@ class UploadOverlay extends HookConsumerWidget {
(task) =>
task.status == UploadTaskStatus.pending ||
task.status == UploadTaskStatus.inProgress ||
task.status == UploadTaskStatus.paused,
task.status == UploadTaskStatus.paused ||
task.status == UploadTaskStatus.completed,
)
.toList();
// if (activeTasks.isEmpty) {
// return const SizedBox.shrink();
// }
if (activeTasks.isEmpty) {
return const SizedBox.shrink();
}
return _UploadOverlayContent(activeTasks: activeTasks);
}
}
class _UploadOverlayContent extends HookConsumerWidget {
final List<UploadTask> activeTasks;
const _UploadOverlayContent({required this.activeTasks});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = useState(false);
final animationController = useAnimationController(
duration: const Duration(milliseconds: 200),
initialValue: 0.0,
);
final heightAnimation = useAnimation(
Tween<double>(begin: 60, end: 400).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
),
);
final opacityAnimation = useAnimation(
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
);
useEffect(() {
if (isExpanded.value) {
animationController.forward();
} else {
animationController.reverse();
}
return null;
}, [isExpanded.value]);
final isMobile = MediaQuery.of(context).size.width < 600;
return Positioned(
bottom: 16,
right: 16,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer,
child: Container(
width: 320,
constraints: BoxConstraints(maxHeight: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
bottom: isMobile ? 16 : 24,
left: isMobile ? 16 : null,
right: isMobile ? 16 : 24,
child: GestureDetector(
onTap: () => isExpanded.value = !isExpanded.value,
child: AnimatedBuilder(
animation: animationController,
builder: (context, child) {
return Material(
elevation: 8 + (opacityAnimation * 4),
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: isMobile ? MediaQuery.of(context).size.width - 32 : 320,
height: heightAnimation,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Collapsed Header
Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Upload icon with animation
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
child: Icon(
key: ValueKey(isExpanded.value),
isExpanded.value
? Symbols.expand_more
: Symbols.upload,
size: 24,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
// Title and count
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isExpanded.value
? 'uploadTasks'.tr()
: '${activeTasks.length} ${'uploading'.tr()}',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (!isExpanded.value &&
activeTasks.isNotEmpty)
Text(
_getOverallProgressText(activeTasks),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
// Progress indicator (collapsed)
if (!isExpanded.value)
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: _getOverallProgress(activeTasks),
strokeWidth: 3,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
),
// Expand/collapse button
IconButton(
icon: AnimatedRotation(
turns: opacityAnimation * 0.5,
duration: const Duration(milliseconds: 200),
child: Icon(
isExpanded.value
? Symbols.expand_more
: Symbols.chevron_right,
size: 20,
),
),
onPressed:
() => isExpanded.value = !isExpanded.value,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
// Expanded content
if (isExpanded.value)
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
children: [
// Clear completed tasks button
if (_hasCompletedTasks(activeTasks))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
ref
.read(
uploadTasksProvider.notifier,
)
.clearCompletedTasks();
},
icon: Icon(
Symbols.clear_all,
size: 18,
),
label: const Text('Clear Completed'),
style: TextButton.styleFrom(
foregroundColor:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
// Task list
Expanded(
child: AnimatedOpacity(
opacity: opacityAnimation,
duration: const Duration(milliseconds: 150),
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: activeTasks.length,
itemBuilder: (context, index) {
final task = activeTasks[index];
return UploadTaskTile(task: task);
},
),
),
),
],
),
),
),
],
),
),
child: Row(
children: [
Icon(
Symbols.upload,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'uploadTasks'.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
'${activeTasks.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Task list
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: activeTasks.length,
itemBuilder: (context, index) {
final task = activeTasks[index];
return UploadTaskTile(task: task);
},
),
),
],
),
);
},
),
),
);
}
double _getOverallProgress(List<UploadTask> tasks) {
if (tasks.isEmpty) return 0.0;
final totalProgress = tasks.fold<double>(
0.0,
(sum, task) => sum + task.progress,
);
return totalProgress / tasks.length;
}
String _getOverallProgressText(List<UploadTask> tasks) {
final overallProgress = _getOverallProgress(tasks);
return '${(overallProgress * 100).toStringAsFixed(0)}%';
}
bool _hasCompletedTasks(List<UploadTask> tasks) {
return tasks.any(
(task) =>
task.status == UploadTaskStatus.completed ||
task.status == UploadTaskStatus.failed ||
task.status == UploadTaskStatus.cancelled ||
task.status == UploadTaskStatus.expired,
);
}
}
class UploadTaskTile extends HookConsumerWidget {
@@ -219,6 +417,8 @@ class UploadTaskTile extends HookConsumerWidget {
}
Widget _buildExpandedDetails(BuildContext context) {
final transmissionProgress = task.transmissionProgress ?? 0.0;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -228,7 +428,15 @@ class UploadTaskTile extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Progress text
// Server Processing Progress
Text(
'Server Processing',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -244,10 +452,7 @@ class UploadTaskTile extends HookConsumerWidget {
),
],
),
const SizedBox(height: 4),
// Progress bar
LinearProgressIndicator(
value: task.progress,
backgroundColor: Theme.of(context).colorScheme.surface,
@@ -256,6 +461,41 @@ class UploadTaskTile extends HookConsumerWidget {
),
),
const SizedBox(height: 8),
// File Transmission Progress
Text(
'File Transmission',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.secondary,
),
),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${(transmissionProgress * 100).toStringAsFixed(1)}%',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
Text(
'${_formatFileSize((transmissionProgress * task.fileSize).toInt())} / ${_formatFileSize(task.fileSize)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: transmissionProgress,
backgroundColor: Theme.of(context).colorScheme.surface,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.secondary,
),
),
const SizedBox(height: 4),
// Speed and ETA