328 lines
10 KiB
Dart
328 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:island/models/upload_task.dart';
|
|
import 'package:island/pods/upload_tasks.dart';
|
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
|
|
class UploadOverlay extends HookConsumerWidget {
|
|
const UploadOverlay({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final uploadTasks = ref.watch(uploadTasksProvider);
|
|
final activeTasks =
|
|
uploadTasks
|
|
.where(
|
|
(task) =>
|
|
task.status == UploadTaskStatus.pending ||
|
|
task.status == UploadTaskStatus.inProgress ||
|
|
task.status == UploadTaskStatus.paused,
|
|
)
|
|
.toList();
|
|
|
|
if (activeTasks.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UploadTaskTile extends HookConsumerWidget {
|
|
final UploadTask task;
|
|
|
|
const UploadTaskTile({super.key, required this.task});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final isExpanded = useState(false);
|
|
|
|
return InkWell(
|
|
onTap: () => isExpanded.value = !isExpanded.value,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
// Status icon
|
|
_buildStatusIcon(context),
|
|
const SizedBox(width: 8),
|
|
|
|
// File info
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
task.fileName,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_formatFileSize(task.fileSize),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Progress indicator
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: CircularProgressIndicator(
|
|
value: task.progress,
|
|
strokeWidth: 3,
|
|
backgroundColor:
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
),
|
|
|
|
// Expand/collapse button
|
|
IconButton(
|
|
icon: Icon(
|
|
isExpanded.value
|
|
? Symbols.expand_less
|
|
: Symbols.expand_more,
|
|
size: 16,
|
|
),
|
|
onPressed: () => isExpanded.value = !isExpanded.value,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Expanded details
|
|
if (isExpanded.value) ...[
|
|
const SizedBox(height: 8),
|
|
_buildExpandedDetails(context),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusIcon(BuildContext context) {
|
|
IconData icon;
|
|
Color color;
|
|
|
|
switch (task.status) {
|
|
case UploadTaskStatus.pending:
|
|
icon = Symbols.schedule;
|
|
color = Theme.of(context).colorScheme.secondary;
|
|
break;
|
|
case UploadTaskStatus.inProgress:
|
|
icon = Symbols.upload;
|
|
color = Theme.of(context).colorScheme.primary;
|
|
break;
|
|
case UploadTaskStatus.paused:
|
|
icon = Symbols.pause_circle;
|
|
color = Theme.of(context).colorScheme.tertiary;
|
|
break;
|
|
case UploadTaskStatus.completed:
|
|
icon = Symbols.check_circle;
|
|
color = Colors.green;
|
|
break;
|
|
case UploadTaskStatus.failed:
|
|
icon = Symbols.error;
|
|
color = Theme.of(context).colorScheme.error;
|
|
break;
|
|
case UploadTaskStatus.cancelled:
|
|
icon = Symbols.cancel;
|
|
color = Theme.of(context).colorScheme.error;
|
|
break;
|
|
case UploadTaskStatus.expired:
|
|
icon = Symbols.timer_off;
|
|
color = Theme.of(context).colorScheme.error;
|
|
break;
|
|
}
|
|
|
|
return Icon(icon, size: 16, color: color);
|
|
}
|
|
|
|
Widget _buildExpandedDetails(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Progress text
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'${(task.progress * 100).toStringAsFixed(1)}%',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
Text(
|
|
'${task.uploadedChunks}/${task.totalChunks} chunks',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
// Progress bar
|
|
LinearProgressIndicator(
|
|
value: task.progress,
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
// Speed and ETA
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
_formatBytesPerSecond(task),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (task.status == UploadTaskStatus.inProgress)
|
|
Text(
|
|
'ETA: ${_formatDuration(task.estimatedTimeRemaining)}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Error message if failed
|
|
if (task.errorMessage != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
task.errorMessage!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatFileSize(int bytes) {
|
|
if (bytes >= 1073741824) {
|
|
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
|
|
} else if (bytes >= 1048576) {
|
|
return '${(bytes / 1048576).toStringAsFixed(1)} MB';
|
|
} else if (bytes >= 1024) {
|
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
} else {
|
|
return '$bytes bytes';
|
|
}
|
|
}
|
|
|
|
String _formatBytesPerSecond(UploadTask task) {
|
|
if (task.uploadedBytes == 0) return '0 B/s';
|
|
|
|
final elapsedSeconds = DateTime.now().difference(task.createdAt).inSeconds;
|
|
if (elapsedSeconds == 0) return '0 B/s';
|
|
|
|
final bytesPerSecond = task.uploadedBytes / elapsedSeconds;
|
|
return '${_formatFileSize(bytesPerSecond.toInt())}/s';
|
|
}
|
|
|
|
String _formatDuration(Duration duration) {
|
|
if (duration.inHours > 0) {
|
|
return '${duration.inHours}h ${duration.inMinutes.remainder(60)}m';
|
|
} else if (duration.inMinutes > 0) {
|
|
return '${duration.inMinutes}m ${duration.inSeconds.remainder(60)}s';
|
|
} else {
|
|
return '${duration.inSeconds}s';
|
|
}
|
|
}
|
|
}
|