diff --git a/lib/api/guangya_api.dart b/lib/api/guangya_api.dart index 981d925..e6d9049 100644 --- a/lib/api/guangya_api.dart +++ b/lib/api/guangya_api.dart @@ -659,12 +659,14 @@ class GuangyaAPI { String? parentID, String contentType = 'application/octet-stream', int chunkSize = 5 * 1024 * 1024, + void Function(int sent, int total)? onProgress, }) async { final bytes = await file.readAsBytes(); final name = file.uri.pathSegments.isEmpty ? file.path.split(Platform.pathSeparator).last : Uri.decodeComponent(file.uri.pathSegments.last); final size = bytes.length; + onProgress?.call(0, size); Map token; if (size < 1024 * 1024) { @@ -676,8 +678,13 @@ class GuangyaAPI { md5: md5Base64, ); final taskID = _findStringDeep(token, ['taskId', 'task_id']); - if (taskID == null) return token; - return uploadInfo(taskID); + if (taskID == null) { + onProgress?.call(size, size); + return token; + } + final result = await uploadInfo(taskID); + onProgress?.call(size, size); + return result; } token = await uploadToken(name: name, fileSize: size, parentID: parentID); @@ -688,7 +695,9 @@ class GuangyaAPI { final canFlash = await checkCanFlashUpload(taskID, gcid); if (_findBoolDeep(canFlash, ['canFlashUpload', 'can_flash_upload']) == true) { - return uploadInfo(taskID); + final result = await uploadInfo(taskID); + onProgress?.call(size, size); + return result; } final tokenData = _findMapDeep(token, ['data']) ?? token; @@ -697,8 +706,11 @@ class GuangyaAPI { tokenData: tokenData, contentType: contentType, chunkSize: chunkSize, + onProgress: onProgress, ); - return uploadInfo(taskID); + final result = await uploadInfo(taskID); + onProgress?.call(size, size); + return result; } Future _cdnUpload( @@ -706,6 +718,7 @@ class GuangyaAPI { required Map tokenData, required String contentType, required int chunkSize, + void Function(int sent, int total)? onProgress, }) async { final creds = _findMapDeep(tokenData, ['creds']) ?? const {}; @@ -765,6 +778,7 @@ class GuangyaAPI { MapEntry(number, response.headers['etag']?.replaceAll('"', '') ?? ''), ); offset = end; + onProgress?.call(offset, bytes.length); number += 1; } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 9a323cb..6d574b9 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -351,7 +351,7 @@ class _SettingsDialogState extends ConsumerState { TextEditingController controller, { required String placeholder, }) => SizedBox( - width: 280, + width: double.infinity, child: ShadInput(controller: controller, placeholder: Text(placeholder)), ); @@ -359,7 +359,7 @@ class _SettingsDialogState extends ConsumerState { TextEditingController controller, { String? placeholder, }) => SizedBox( - width: 116, + width: double.infinity, child: ShadInput( controller: controller, placeholder: placeholder == null ? null : Text(placeholder), @@ -475,37 +475,47 @@ class _SettingsSection extends StatelessWidget { @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 30, - height: 30, - alignment: Alignment.center, - decoration: BoxDecoration( - color: cs.primary.withValues(alpha: 0.11), - borderRadius: BorderRadius.circular(7), + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.11), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(icon, size: 16, color: cs.primary), ), - child: Icon(icon, size: 16, color: cs.primary), - ), - const SizedBox(width: 10), - Text( - title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: cs.foreground, + const SizedBox(width: 10), + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: cs.foreground, + ), ), - ), - ], - ), - const SizedBox(height: 10), - const ShadSeparator.horizontal(), - const SizedBox(height: 4), - child, - ], + ], + ), + const SizedBox(height: 12), + const ShadSeparator.horizontal(), + const SizedBox(height: 4), + child, + ], + ), ); } } @@ -532,22 +542,16 @@ class _SettingsRow extends StatelessWidget { Text(label, style: TextStyle(fontSize: 13, color: cs.foreground)), ], ); - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 470) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [labelWidget, const SizedBox(height: 8), child], - ), - ); - } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row(children: [labelWidget, const Spacer(), child]), - ); - }, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + labelWidget, + const SizedBox(height: 8), + SizedBox(width: double.infinity, child: child), + ], + ), ); } } diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 0ce25cd..01cc9a9 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -599,31 +599,21 @@ class _TopBar extends StatelessWidget { _SegmentButton( icon: Icons.folder_rounded, label: '光鸭云盘', - compact: false, + compact: true, selected: mode == WorkspaceMode.cloud, onTap: () => onModeChanged(WorkspaceMode.cloud), ), _SegmentButton( icon: Icons.movie_rounded, label: '光鸭影视', - compact: false, + compact: true, selected: mode == WorkspaceMode.media, onTap: () => onModeChanged(WorkspaceMode.media), ), - ShadTooltip( - builder: (_) => const Text('设置'), - child: SizedBox( - width: 34, - height: 32, - child: GestureDetector( - onTap: onSettings, - child: Icon( - Icons.settings_rounded, - size: 17, - color: cs.mutedForeground, - ), - ), - ), + _TopBarIconButton( + tooltip: '设置', + icon: Icons.settings_rounded, + onTap: onSettings, ), ], ), @@ -705,46 +695,54 @@ class _SegmentButton extends StatelessWidget { @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; - return InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: compact ? 38 : 126, - height: compact ? 38 : 32, - decoration: BoxDecoration( - color: selected ? cs.secondary : Colors.transparent, + return ShadTooltip( + builder: (_) => Text(label), + child: Semantics( + button: true, + selected: selected, + label: label, + child: InkWell( borderRadius: BorderRadius.circular(10), - boxShadow: selected - ? [ - BoxShadow( - color: cs.border.withValues(alpha: 0.7), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ] - : null, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 15, - color: selected ? cs.foreground : cs.mutedForeground, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: compact ? 38 : 126, + height: compact ? 38 : 32, + decoration: BoxDecoration( + color: selected ? cs.secondary : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: selected + ? [ + BoxShadow( + color: cs.border.withValues(alpha: 0.7), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : null, ), - if (!compact) ...[ - const SizedBox(width: 7), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 15, color: selected ? cs.foreground : cs.mutedForeground, ), - ), - ], - ], + if (!compact) ...[ + const SizedBox(width: 7), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: selected ? cs.foreground : cs.mutedForeground, + ), + ), + ], + ], + ), + ), ), ), ); @@ -1519,12 +1517,20 @@ class _CloudToolbar extends ConsumerWidget { ], ), const SizedBox(width: 8), - _ToolbarButton( - icon: Icons.upload_rounded, - label: '上传', - primary: true, - compact: compact, - onTap: () => _pickAndUpload(ref), + ShadPopover( + visible: state.uploadProgress?.isActive == true, + closeOnTapOutside: false, + popover: (_) => + _UploadProgressPopover(progress: state.uploadProgress), + child: _ToolbarButton( + icon: Icons.upload_rounded, + label: '上传', + primary: true, + compact: compact, + onTap: state.uploadProgress?.isActive == true + ? null + : () => _pickAndUpload(ref), + ), ), const SizedBox(width: 8), _ToolbarControlGroup( @@ -1677,7 +1683,7 @@ class _FileViewButtons extends StatelessWidget { class _ToolbarButton extends StatelessWidget { final IconData icon; final String label; - final VoidCallback onTap; + final VoidCallback? onTap; final bool primary; final bool grouped; final bool selected; @@ -1696,6 +1702,7 @@ class _ToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; + final disabled = onTap == null; return ShadTooltip( builder: (_) => Text(label), child: Padding( @@ -1713,7 +1720,7 @@ class _ToolbarButton extends StatelessWidget { ), decoration: BoxDecoration( color: primary - ? cs.primary + ? cs.primary.withValues(alpha: disabled ? 0.55 : 1) : selected ? cs.primary.withValues(alpha: 0.14) : grouped @@ -1762,6 +1769,99 @@ class _ToolbarButton extends StatelessWidget { } } +class _UploadProgressPopover extends StatelessWidget { + final UploadProgress? progress; + + const _UploadProgressPopover({required this.progress}); + + @override + Widget build(BuildContext context) { + final value = progress; + if (value == null) return const SizedBox.shrink(); + final cs = ShadTheme.of(context).colorScheme; + final percentage = (value.fraction * 100).round(); + return Semantics( + label: '上传进度', + value: '已完成 ${value.processedFiles} / ${value.totalFiles},$percentage%', + child: SizedBox( + width: 292, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.upload_rounded, size: 17, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + '正在上传', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: cs.foreground, + ), + ), + ), + Text( + '$percentage%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.primary, + ), + ), + ], + ), + const SizedBox(height: 10), + ShadProgress( + value: value.fraction, + minHeight: 8, + semanticsLabel: '上传进度', + semanticsValue: '$percentage%', + ), + const SizedBox(height: 9), + Text( + value.currentFileName.isEmpty + ? '正在整理上传结果' + : value.currentFileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: cs.foreground), + ), + const SizedBox(height: 4), + Text( + '已完成 ${value.completedFiles} / ${value.totalFiles} · ${_formatUploadBytes(value.transferredBytes)} / ${_formatUploadBytes(value.totalBytes)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + if (value.failedFiles > 0) ...[ + const SizedBox(height: 4), + Text( + '${value.failedFiles} 个文件上传失败,队列会继续处理其余文件。', + style: TextStyle(fontSize: 11, color: cs.destructive), + ), + ], + ], + ), + ), + ); + } + + String _formatUploadBytes(int bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + final precision = unit == 0 ? 0 : (value >= 100 ? 0 : 1); + return '${value.toStringAsFixed(precision)} ${units[unit]}'; + } +} + class _ToolbarControlGroup extends StatelessWidget { final List children; @@ -3513,12 +3613,9 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { _error = null; }); try { - final api = ref.read(authProvider.notifier).api; - for (final file in files) { - if (await file.exists()) { - await api.fileUpload(file, parentID: parentID); - } - } + await ref + .read(fileProvider.notifier) + .uploadLocalFiles(files, parentID: parentID); await _load(parentID: _currentParentID); } catch (e) { setState(() => _error = e.toString()); diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index c468099..3bd7aa7 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -60,6 +60,36 @@ extension FileSortExt on FileSort { enum SortDirection { ascending, descending } +class UploadProgress { + final int totalFiles; + final int completedFiles; + final int failedFiles; + final int totalBytes; + final int transferredBytes; + final String currentFileName; + final bool isActive; + + const UploadProgress({ + required this.totalFiles, + required this.completedFiles, + required this.failedFiles, + required this.totalBytes, + required this.transferredBytes, + required this.currentFileName, + required this.isActive, + }); + + int get processedFiles => completedFiles + failedFiles; + + double get fraction { + if (totalBytes > 0) { + return (transferredBytes / totalBytes).clamp(0.0, 1.0); + } + if (totalFiles == 0) return 0; + return (processedFiles / totalFiles).clamp(0.0, 1.0); + } +} + enum WorkspaceSection { files('全部', 'folder'), recentViewed('最近查看', 'clock'), @@ -93,6 +123,7 @@ class FileState { final Set selectedIDs; final List? clipboard; final bool clipboardIsMove; + final UploadProgress? uploadProgress; const FileState({ this.section = WorkspaceSection.files, @@ -109,6 +140,7 @@ class FileState { this.selectedIDs = const {}, this.clipboard, this.clipboardIsMove = false, + this.uploadProgress, }); bool get hasSelection => selectedIDs.isNotEmpty; @@ -132,6 +164,8 @@ class FileState { List? clipboard, bool clearClipboard = false, bool? clipboardIsMove, + UploadProgress? uploadProgress, + bool clearUploadProgress = false, }) { return FileState( section: section ?? this.section, @@ -148,6 +182,9 @@ class FileState { selectedIDs: selectedIDs ?? this.selectedIDs, clipboard: clearClipboard ? null : (clipboard ?? this.clipboard), clipboardIsMove: clipboardIsMove ?? this.clipboardIsMove, + uploadProgress: clearUploadProgress + ? null + : (uploadProgress ?? this.uploadProgress), ); } } @@ -1048,19 +1085,99 @@ class FileNotifier extends StateNotifier { Future uploadLocalFiles(List files, {String? parentID}) async { if (_api == null || files.isEmpty) return; final targetParentID = parentID ?? _currentParentID; - state = state.copyWith(statusMessage: '正在上传 ${files.length} 个文件…'); + final uploadFiles = []; + var totalBytes = 0; + for (final file in files) { + if (!await file.exists()) continue; + uploadFiles.add(file); + totalBytes += await file.length(); + } + if (uploadFiles.isEmpty) { + state = state.copyWith(errorMessage: '没有可上传的本地文件'); + return; + } + + UploadProgress progress({ + required int completed, + required int failed, + required int transferred, + required String currentName, + required bool active, + }) => UploadProgress( + totalFiles: uploadFiles.length, + completedFiles: completed, + failedFiles: failed, + totalBytes: totalBytes, + transferredBytes: transferred, + currentFileName: currentName, + isActive: active, + ); + + state = state.copyWith( + statusMessage: '正在上传 ${uploadFiles.length} 个文件…', + uploadProgress: progress( + completed: 0, + failed: 0, + transferred: 0, + currentName: uploadFiles.first.uri.pathSegments.last, + active: true, + ), + ); var completed = 0; - try { - for (final file in files) { - if (!await file.exists()) continue; - state = state.copyWith( - statusMessage: - '正在上传 ${completed + 1}/${files.length}:${file.uri.pathSegments.last}', + var failed = 0; + var completedBytes = 0; + for (final file in uploadFiles) { + final fileSize = await file.length(); + final fileName = file.uri.pathSegments.last; + state = state.copyWith( + statusMessage: + '正在上传 ${completed + failed + 1}/${uploadFiles.length}:$fileName', + uploadProgress: progress( + completed: completed, + failed: failed, + transferred: completedBytes, + currentName: fileName, + active: true, + ), + ); + try { + await _api!.fileUpload( + file, + parentID: targetParentID, + onProgress: (sent, total) { + final currentTransferred = + completedBytes + sent.clamp(0, total == 0 ? 0 : total).toInt(); + state = state.copyWith( + uploadProgress: progress( + completed: completed, + failed: failed, + transferred: currentTransferred, + currentName: fileName, + active: true, + ), + ); + }, ); - await _api!.fileUpload(file, parentID: targetParentID); completed += 1; + completedBytes += fileSize; + } catch (_) { + failed += 1; + completedBytes += fileSize; } - state = state.copyWith(statusMessage: '已上传 $completed 个文件'); + } + state = state.copyWith( + statusMessage: failed == 0 + ? '已上传 $completed 个文件' + : '已上传 $completed 个文件,失败 $failed 个', + uploadProgress: progress( + completed: completed, + failed: failed, + transferred: completedBytes, + currentName: '', + active: false, + ), + ); + try { await _invalidateFolderCaches(targetParentID); await loadFiles(parentID: _currentParentID); } catch (e) { diff --git a/test/upload_progress_test.dart b/test/upload_progress_test.dart new file mode 100644 index 0000000..b45d914 --- /dev/null +++ b/test/upload_progress_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:guangya_flutter/providers/file_provider.dart'; + +void main() { + test('UploadProgress reports byte-based progress when bytes are known', () { + const progress = UploadProgress( + totalFiles: 3, + completedFiles: 1, + failedFiles: 0, + totalBytes: 400, + transferredBytes: 100, + currentFileName: 'movie.mkv', + isActive: true, + ); + + expect(progress.processedFiles, 1); + expect(progress.fraction, 0.25); + }); + + test('UploadProgress falls back to processed files for empty files', () { + const progress = UploadProgress( + totalFiles: 2, + completedFiles: 1, + failedFiles: 1, + totalBytes: 0, + transferredBytes: 0, + currentFileName: '', + isActive: false, + ); + + expect(progress.processedFiles, 2); + expect(progress.fraction, 1); + }); +}