Compare commits

...

2 Commits

Author SHA1 Message Date
b40afde00f 💫 Animated the upload overlay 2025-11-10 01:40:28 +08:00
78a4022531 💄 Optimize upload overlay styling 2025-11-10 01:27:06 +08:00

View File

@@ -1,8 +1,12 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/upload_task.dart'; import 'package:island/models/upload_task.dart';
import 'package:island/pods/upload_tasks.dart'; import 'package:island/pods/upload_tasks.dart';
import 'package:island/services/responsive.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@@ -21,12 +25,44 @@ class UploadOverlay extends HookConsumerWidget {
task.status == UploadTaskStatus.paused || task.status == UploadTaskStatus.paused ||
task.status == UploadTaskStatus.completed, task.status == UploadTaskStatus.completed,
) )
.toList(); .toList()
// if (activeTasks.isEmpty) { ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Newest first
// return const SizedBox.shrink();
// }
return _UploadOverlayContent(activeTasks: activeTasks); final isVisible = activeTasks.isNotEmpty;
final slideController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final slideAnimation = Tween<Offset>(
begin: const Offset(0, 1), // Start from below the screen
end: Offset.zero, // End at normal position
).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut));
// Animate when visibility changes
useEffect(() {
if (isVisible) {
slideController.forward();
} else {
slideController.reverse();
}
return null;
}, [isVisible]);
if (!isVisible && slideController.status == AnimationStatus.dismissed) {
// If not visible and animation is complete (back to start), don't show anything
return const SizedBox.shrink();
}
final isDesktop = isWideScreen(context);
return Positioned(
bottom: 16 + MediaQuery.of(context).padding.bottom,
left: isDesktop ? null : 0,
right: isDesktop ? 24 : 0,
child: SlideTransition(
position: slideAnimation,
child: _UploadOverlayContent(activeTasks: activeTasks),
),
);
} }
} }
@@ -103,8 +139,8 @@ class _UploadOverlayContent extends HookConsumerWidget {
child: Icon( child: Icon(
key: ValueKey(isExpanded.value), key: ValueKey(isExpanded.value),
isExpanded.value isExpanded.value
? Symbols.expand_more ? Symbols.list_rounded
: Symbols.upload, : _getOverallStatusIcon(activeTasks),
size: 24, size: 24,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
@@ -120,7 +156,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
Text( Text(
isExpanded.value isExpanded.value
? 'uploadTasks'.tr() ? 'uploadTasks'.tr()
: '${activeTasks.length} ${'uploading'.tr()}', : _getOverallStatusText(activeTasks),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleSmall .titleSmall
@@ -188,70 +224,56 @@ class _UploadOverlayContent extends HookConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(
color: Theme.of(context).dividerColor, color: Theme.of(context).colorScheme.outline,
width: 1, width:
1 /
MediaQuery.of(context).devicePixelRatio,
), ),
), ),
), ),
child: Column( child: CustomScrollView(
children: [ slivers: [
// Clear completed tasks button // Clear completed tasks button
if (_hasCompletedTasks(activeTasks)) if (_hasCompletedTasks(activeTasks))
Container( SliverToBoxAdapter(
padding: const EdgeInsets.symmetric( child: ListTile(
horizontal: 16, dense: true,
vertical: 8, title: const Text('Clear Completed'),
), leading: Icon(
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, Symbols.clear_all,
size: 18, size: 18,
), color:
label: const Text('Clear Completed'),
style: TextButton.styleFrom(
foregroundColor:
Theme.of( Theme.of(
context, context,
).colorScheme.onSurfaceVariant, ).colorScheme.onSurfaceVariant,
), ),
), onTap: () {
], ref
.read(uploadTasksProvider.notifier)
.clearCompletedTasks();
},
tileColor:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
), ),
), ),
// Task list // Task list
Expanded( SliverList(
child: AnimatedOpacity( delegate: SliverChildBuilderDelegate((
opacity: opacityAnimation, context,
duration: const Duration(milliseconds: 150), index,
child: ListView.builder( ) {
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: activeTasks.length,
itemBuilder: (context, index) {
final task = activeTasks[index]; final task = activeTasks[index];
return UploadTaskTile(task: task); return AnimatedOpacity(
}, opacity: opacityAnimation,
), duration: const Duration(
milliseconds: 150,
), ),
child: UploadTaskTile(task: task),
);
}, childCount: activeTasks.length),
), ),
], ],
), ),
@@ -272,7 +294,13 @@ class _UploadOverlayContent extends HookConsumerWidget {
if (tasks.isEmpty) return 0.0; if (tasks.isEmpty) return 0.0;
final totalProgress = tasks.fold<double>( final totalProgress = tasks.fold<double>(
0.0, 0.0,
(sum, task) => sum + task.progress, (sum, task) =>
sum +
(task.status == UploadTaskStatus.inProgress
? task.progress
: task.status == UploadTaskStatus.completed
? 1
: 0),
); );
return totalProgress / tasks.length; return totalProgress / tasks.length;
} }
@@ -282,6 +310,82 @@ class _UploadOverlayContent extends HookConsumerWidget {
return '${(overallProgress * 100).toStringAsFixed(0)}%'; return '${(overallProgress * 100).toStringAsFixed(0)}%';
} }
IconData _getOverallStatusIcon(List<UploadTask> tasks) {
if (tasks.isEmpty) return Symbols.upload;
final hasInProgress = tasks.any(
(task) => task.status == UploadTaskStatus.inProgress,
);
final hasPending = tasks.any(
(task) => task.status == UploadTaskStatus.pending,
);
final hasPaused = tasks.any(
(task) => task.status == UploadTaskStatus.paused,
);
final hasFailed = tasks.any(
(task) =>
task.status == UploadTaskStatus.failed ||
task.status == UploadTaskStatus.cancelled ||
task.status == UploadTaskStatus.expired,
);
final hasCompleted = tasks.any(
(task) => task.status == UploadTaskStatus.completed,
);
// Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) {
return Symbols.upload;
} else if (hasPending) {
return Symbols.schedule;
} else if (hasPaused) {
return Symbols.pause_circle;
} else if (hasFailed) {
return Symbols.error;
} else if (hasCompleted) {
return Symbols.check_circle;
} else {
return Symbols.upload;
}
}
String _getOverallStatusText(List<UploadTask> tasks) {
if (tasks.isEmpty) return '0 tasks';
final hasInProgress = tasks.any(
(task) => task.status == UploadTaskStatus.inProgress,
);
final hasPending = tasks.any(
(task) => task.status == UploadTaskStatus.pending,
);
final hasPaused = tasks.any(
(task) => task.status == UploadTaskStatus.paused,
);
final hasFailed = tasks.any(
(task) =>
task.status == UploadTaskStatus.failed ||
task.status == UploadTaskStatus.cancelled ||
task.status == UploadTaskStatus.expired,
);
final hasCompleted = tasks.any(
(task) => task.status == UploadTaskStatus.completed,
);
// Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) {
return '${tasks.length} ${'uploading'.tr()}';
} else if (hasPending) {
return '${tasks.length} ${'pending'.tr()}';
} else if (hasPaused) {
return '${tasks.length} ${'paused'.tr()}';
} else if (hasFailed) {
return '${tasks.length} ${'failed'.tr()}';
} else if (hasCompleted) {
return '${tasks.length} ${'completed'.tr()}';
} else {
return '${tasks.length} ${'tasks'.tr()}';
}
}
bool _hasCompletedTasks(List<UploadTask> tasks) { bool _hasCompletedTasks(List<UploadTask> tasks) {
return tasks.any( return tasks.any(
(task) => (task) =>
@@ -293,88 +397,108 @@ class _UploadOverlayContent extends HookConsumerWidget {
} }
} }
class UploadTaskTile extends HookConsumerWidget { class UploadTaskTile extends StatefulWidget {
final UploadTask task; final UploadTask task;
const UploadTaskTile({super.key, required this.task}); const UploadTaskTile({super.key, required this.task});
@override @override
Widget build(BuildContext context, WidgetRef ref) { State<UploadTaskTile> createState() => _UploadTaskTileState();
final isExpanded = useState(false); }
return InkWell( class _UploadTaskTileState extends State<UploadTaskTile>
onTap: () => isExpanded.value = !isExpanded.value, with TickerProviderStateMixin {
child: Container( late AnimationController _rotationController;
padding: const EdgeInsets.all(12), late Animation<double> _rotationAnimation;
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Status icon
_buildStatusIcon(context),
const SizedBox(width: 8),
// File info @override
Expanded( void initState() {
child: Column( super.initState();
_rotationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_rotationAnimation = Tween<double>(begin: 0.0, end: 0.5).animate(
CurvedAnimation(parent: _rotationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ExpansionTile(
leading: _buildStatusIcon(context),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
task.fileName, widget.task.fileName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.w500, context,
), ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
_formatFileSize(task.fileSize), _formatFileSize(widget.task.fileSize),
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
), trailing: Row(
mainAxisSize: MainAxisSize.min,
// Progress indicator children: [
const SizedBox(width: 8),
SizedBox( SizedBox(
width: 40, width: 32,
height: 40, height: 32,
child: Padding(
padding: const EdgeInsets.all(2),
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: task.progress, value: widget.task.progress,
strokeWidth: 3, strokeWidth: 2.5,
backgroundColor: backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest, 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, const Gap(4),
padding: EdgeInsets.zero, AnimatedBuilder(
constraints: const BoxConstraints(), animation: _rotationAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value * math.pi,
child: Icon(
Symbols.expand_more,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
},
), ),
], ],
), ),
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
// Expanded details onExpansionChanged: (expanded) {
if (isExpanded.value) ...[ if (expanded) {
const SizedBox(height: 8), _rotationController.forward();
_buildExpandedDetails(context), } else {
], _rotationController.reverse();
], }
), },
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: _buildExpandedDetails(context),
), ),
],
); );
} }
@@ -382,7 +506,7 @@ class UploadTaskTile extends HookConsumerWidget {
IconData icon; IconData icon;
Color color; Color color;
switch (task.status) { switch (widget.task.status) {
case UploadTaskStatus.pending: case UploadTaskStatus.pending:
icon = Symbols.schedule; icon = Symbols.schedule;
color = Theme.of(context).colorScheme.secondary; color = Theme.of(context).colorScheme.secondary;
@@ -413,11 +537,11 @@ class UploadTaskTile extends HookConsumerWidget {
break; break;
} }
return Icon(icon, size: 16, color: color); return Icon(icon, size: 24, color: color);
} }
Widget _buildExpandedDetails(BuildContext context) { Widget _buildExpandedDetails(BuildContext context) {
final transmissionProgress = task.transmissionProgress ?? 0.0; final transmissionProgress = widget.task.transmissionProgress ?? 0.0;
return Container( return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -441,20 +565,20 @@ class UploadTaskTile extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'${(task.progress * 100).toStringAsFixed(1)}%', '${(widget.task.progress * 100).toStringAsFixed(1)}%',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
), ),
Text( Text(
'${task.uploadedChunks}/${task.totalChunks} chunks', '${widget.task.uploadedChunks}/${widget.task.totalChunks} chunks',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
LinearProgressIndicator( LinearProgressIndicator(
value: task.progress, value: widget.task.progress,
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
@@ -482,7 +606,7 @@ class UploadTaskTile extends HookConsumerWidget {
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
), ),
Text( Text(
'${_formatFileSize((transmissionProgress * task.fileSize).toInt())} / ${_formatFileSize(task.fileSize)}', '${_formatFileSize((transmissionProgress * widget.task.fileSize).toInt())} / ${_formatFileSize(widget.task.fileSize)}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -503,14 +627,14 @@ class UploadTaskTile extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
_formatBytesPerSecond(task), _formatBytesPerSecond(widget.task),
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
if (task.status == UploadTaskStatus.inProgress) if (widget.task.status == UploadTaskStatus.inProgress)
Text( Text(
'ETA: ${_formatDuration(task.estimatedTimeRemaining)}', 'ETA: ${_formatDuration(widget.task.estimatedTimeRemaining)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
@@ -519,10 +643,10 @@ class UploadTaskTile extends HookConsumerWidget {
), ),
// Error message if failed // Error message if failed
if (task.errorMessage != null) ...[ if (widget.task.errorMessage != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
task.errorMessage!, widget.task.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
), ),