✨ Upload tasks overlay
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user