♻️ Better task overlay progress

This commit is contained in:
2026-01-11 13:11:15 +08:00
parent bf59108569
commit 88c4d648d5
2 changed files with 196 additions and 94 deletions

View File

@@ -256,6 +256,21 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
}).toList(); }).toList();
} }
void updateUploadProgress(String taskId, int uploadedBytes, int uploadedChunks) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
uploadedBytes: uploadedBytes,
uploadedChunks: uploadedChunks,
status: DriveTaskStatus.inProgress,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void updateDownloadProgress( void updateDownloadProgress(
String taskId, String taskId,
int downloadedBytes, int downloadedBytes,
@@ -470,6 +485,7 @@ class EnhancedFileUploader extends FileUploader {
// Step 2: Upload chunks // Step 2: Upload chunks
int bytesUploaded = 0; int bytesUploaded = 0;
int chunksUploaded = 0;
if (fileData is XFile) { if (fileData is XFile) {
// Use stream for XFile // Use stream for XFile
final subscription = fileData.openRead().listen(null); final subscription = fileData.openRead().listen(null);
@@ -494,6 +510,11 @@ class EnhancedFileUploader extends FileUploader {
}, },
); );
bytesUploaded += chunkData.length; bytesUploaded += chunkData.length;
chunksUploaded += 1;
// Update upload progress in UI
ref
.read(uploadTasksProvider.notifier)
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
} }
subscription.cancel(); subscription.cancel();
} else if (fileData is Uint8List) { } else if (fileData is Uint8List) {
@@ -521,6 +542,11 @@ class EnhancedFileUploader extends FileUploader {
}, },
); );
bytesUploaded += chunks[i].length; bytesUploaded += chunks[i].length;
chunksUploaded += 1;
// Update upload progress in UI
ref
.read(uploadTasksProvider.notifier)
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
} }
} else { } else {
throw ArgumentError('Invalid fileData type'); throw ArgumentError('Invalid fileData type');

View File

@@ -251,7 +251,9 @@ class _TaskOverlayContent extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(
context,
).colorScheme.onSurface,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -260,13 +262,34 @@ class _TaskOverlayContent extends HookConsumerWidget {
SizedBox( SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: CircularProgressIndicator( child: Stack(
value: _getOverallProgress(activeTasks), alignment: Alignment.center,
strokeWidth: 3, children: [
backgroundColor: Theme.of( CircularProgressIndicator(
context, value: _getOverallProgress(activeTasks),
).colorScheme.surfaceContainerHighest, strokeWidth: 3,
padding: EdgeInsets.zero, backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
padding: EdgeInsets.zero,
),
if (activeTasks.any(
(task) =>
task.status == DriveTaskStatus.inProgress,
))
CircularProgressIndicator(
value: null, // Indeterminate
strokeWidth: 3,
trackGap: 0,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(
context,
).colorScheme.secondary.withOpacity(0.5),
),
backgroundColor: Colors.transparent,
padding: EdgeInsets.zero,
),
],
), ),
), ),
], ],
@@ -316,7 +339,9 @@ class _TaskOverlayContent extends HookConsumerWidget {
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleSmall .titleSmall
?.copyWith(fontWeight: FontWeight.w600), ?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -341,12 +366,34 @@ class _TaskOverlayContent extends HookConsumerWidget {
SizedBox( SizedBox(
width: 32, width: 32,
height: 32, height: 32,
child: CircularProgressIndicator( child: Stack(
value: _getOverallProgress(activeTasks), children: [
strokeWidth: 3, CircularProgressIndicator(
backgroundColor: Theme.of( value: _getOverallProgress(activeTasks),
context, strokeWidth: 3,
).colorScheme.surfaceContainerHighest, backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
if (activeTasks.any(
(task) =>
task.status ==
DriveTaskStatus.inProgress,
))
CircularProgressIndicator(
value: null, // Indeterminate
strokeWidth: 3,
trackGap: 0,
valueColor:
AlwaysStoppedAnimation<Color>(
Theme.of(context)
.colorScheme
.secondary
.withOpacity(0.5),
),
backgroundColor: Colors.transparent,
),
],
), ),
), ),
@@ -378,82 +425,90 @@ class _TaskOverlayContent extends HookConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(
color: Theme.of(context).colorScheme.outline, color: Theme.of(
context,
).colorScheme.outline,
width: width:
1 / 1 /
MediaQuery.of(context).devicePixelRatio, MediaQuery.of(context).devicePixelRatio,
), ),
), ),
), ),
child: CustomScrollView( child: Material(
slivers: [ color: Colors.transparent,
// Clear completed tasks button child: CustomScrollView(
if (_hasCompletedTasks(activeTasks)) slivers: [
SliverToBoxAdapter( // Clear completed tasks button
child: ListTile( if (_hasCompletedTasks(activeTasks))
dense: true, SliverToBoxAdapter(
title: const Text('clearCompleted').tr(), child: ListTile(
leading: Icon( dense: true,
Symbols.clear_all, title: const Text(
size: 18, 'clearCompleted',
color: Theme.of( ).tr(),
leading: Icon(
Symbols.clear_all,
size: 18,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
onTap: () {
taskNotifier.clearCompletedTasks();
onExpansionChanged?.call(false);
},
tileColor: Theme.of(
context, context,
).colorScheme.onSurfaceVariant, ).colorScheme.surfaceContainerHighest,
), ),
onTap: () {
taskNotifier.clearCompletedTasks();
onExpansionChanged?.call(false);
},
tileColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
), ),
),
// Clear all tasks button // Clear all tasks button
if (activeTasks.any( if (activeTasks.any(
(task) => (task) =>
task.status != DriveTaskStatus.completed, task.status !=
)) DriveTaskStatus.completed,
SliverToBoxAdapter( ))
child: ListTile( SliverToBoxAdapter(
dense: true, child: ListTile(
title: const Text('Clear All'), dense: true,
leading: Icon( title: const Text('Clear All'),
Symbols.clear_all, leading: Icon(
size: 18, Symbols.clear_all,
color: Theme.of( size: 18,
color: Theme.of(
context,
).colorScheme.error,
),
onTap: () {
taskNotifier.clearAllTasks();
onExpansionChanged?.call(false);
},
tileColor: Theme.of(
context, context,
).colorScheme.error, ).colorScheme.surfaceContainerHighest,
), ),
onTap: () {
taskNotifier.clearAllTasks();
onExpansionChanged?.call(false);
},
tileColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
), ),
),
// Task list // Task list
SliverList( SliverList(
delegate: SliverChildBuilderDelegate(( delegate: SliverChildBuilderDelegate((
context, context,
index, index,
) { ) {
final task = activeTasks[index]; final task = activeTasks[index];
return AnimatedOpacity( return AnimatedOpacity(
opacity: opacityAnimation, opacity: opacityAnimation,
duration: const Duration( duration: const Duration(
milliseconds: 150, milliseconds: 150,
), ),
child: UploadTaskTile(task: task), child: UploadTaskTile(task: task),
); );
}, childCount: activeTasks.length), }, childCount: activeTasks.length),
), ),
], ],
),
), ),
), ),
), ),
@@ -488,19 +543,30 @@ class _TaskOverlayContent extends HookConsumerWidget {
); );
} }
double? _getTaskProgress(DriveTask task) {
if (task.status == DriveTaskStatus.completed) return 1.0;
if (task.status != DriveTaskStatus.inProgress) return 0.0;
// If all bytes are uploaded but still in progress, show indeterminate
if (task.uploadedBytes >= task.fileSize && task.fileSize > 0) {
return null; // Indeterminate progress
}
return task.fileSize > 0 ? task.uploadedBytes / task.fileSize : 0.0;
}
double _getOverallProgress(List<DriveTask> tasks) { double _getOverallProgress(List<DriveTask> tasks) {
if (tasks.isEmpty) return 0.0; if (tasks.isEmpty) return 0.0;
final totalProgress = tasks.fold<double>(0.0, (sum, task) {
final byteProgress = task.fileSize > 0 final progressValues = tasks.map((task) => _getTaskProgress(task));
? task.uploadedBytes / task.fileSize final determinateProgresses = progressValues.where((p) => p != null);
: 0.0;
return sum + if (determinateProgresses.isEmpty) return 0.0;
(task.status == DriveTaskStatus.inProgress
? byteProgress final totalProgress = determinateProgresses.fold<double>(
: task.status == DriveTaskStatus.completed 0.0,
? 1 (sum, progress) => sum + progress!,
: 0); );
});
return totalProgress / tasks.length; return totalProgress / tasks.length;
} }
@@ -625,6 +691,18 @@ class UploadTaskTile extends StatefulWidget {
@override @override
State<UploadTaskTile> createState() => _UploadTaskTileState(); State<UploadTaskTile> createState() => _UploadTaskTileState();
static double? _getTaskProgress(DriveTask task) {
if (task.status == DriveTaskStatus.completed) return 1.0;
if (task.status == DriveTaskStatus.inProgress) return null;
// If all bytes are uploaded but still in progress, show indeterminate
if (task.uploadedBytes >= task.fileSize && task.fileSize > 0) {
return null; // Indeterminate progress
}
return task.fileSize > 0 ? task.uploadedBytes / task.fileSize : 0.0;
}
} }
class _UploadTaskTileState extends State<UploadTaskTile> class _UploadTaskTileState extends State<UploadTaskTile>
@@ -685,9 +763,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
child: Padding( child: Padding(
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: widget.task.fileSize > 0 value: UploadTaskTile._getTaskProgress(widget.task),
? widget.task.uploadedBytes / widget.task.fileSize
: 0.0,
strokeWidth: 2.5, strokeWidth: 2.5,
backgroundColor: Theme.of( backgroundColor: Theme.of(
context, context,
@@ -814,7 +890,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
LinearProgressIndicator( LinearProgressIndicator(
value: widget.task.progress, value: UploadTaskTile._getTaskProgress(widget.task),
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,