Compare commits

...

3 Commits

Author SHA1 Message Date
f92cfafda4 Downloading file tasks 2025-11-18 01:45:15 +08:00
fa208b44d7 🐛 Fix publisher account name shows wrong 2025-11-18 01:31:15 +08:00
94adecafbb 💄 Optimize file detail view styling 2025-11-18 00:32:26 +08:00
6 changed files with 170 additions and 130 deletions

View File

@@ -258,6 +258,24 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
}).toList(); }).toList();
} }
void updateDownloadProgress(
String taskId,
int downloadedBytes,
int totalBytes,
) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
fileSize: totalBytes,
uploadedBytes: downloadedBytes,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void removeTask(String taskId) { void removeTask(String taskId) {
state = state.where((task) => task.taskId != taskId).toList(); state = state.where((task) => task.taskId != taskId).toList();
} }
@@ -291,6 +309,27 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
.toList(); .toList();
} }
String addLocalDownloadTask(SnCloudFile item) {
final taskId =
'download-${item.id}-${DateTime.now().millisecondsSinceEpoch}';
final task = DriveTask(
id: taskId,
taskId: taskId,
fileName: item.name,
contentType: item.mimeType ?? '',
fileSize: 0,
uploadedBytes: 0,
totalChunks: 1,
uploadedChunks: 0,
status: DriveTaskStatus.inProgress,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
type: 'FileDownload',
);
state = [...state, task];
return taskId;
}
@override @override
void dispose() { void dispose() {
_websocketSubscription?.cancel(); _websocketSubscription?.cancel();

View File

@@ -10,6 +10,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/upload_tasks.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -76,7 +78,7 @@ class FileDetailScreen extends HookConsumerWidget {
}, [animationController]); }, [animationController]);
return AppScaffold( return AppScaffold(
isNoBackground: true, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
@@ -86,26 +88,47 @@ class FileDetailScreen extends HookConsumerWidget {
title: Text(item.name.isEmpty ? 'File Details' : item.name), title: Text(item.name.isEmpty ? 'File Details' : item.name),
actions: _buildAppBarActions(context, ref, showInfoSheet), actions: _buildAppBarActions(context, ref, showInfoSheet),
), ),
body: AnimatedBuilder( body: LayoutBuilder(
animation: animation, builder: (context, constraints) {
builder: (context, child) { return AnimatedBuilder(
return Row( animation: animation,
children: [ builder: (context, child) {
// Main content area return Stack(
Expanded(child: _buildContent(context, ref, serverUrl)), children: [
// Animated drawer panel // Main content area - resizes with animation
if (isWide) Positioned(
SizedBox( left: 0,
height: double.infinity, top: 0,
width: animation.value * 400, // Max width of 400px bottom: 0,
child: Container( width: constraints.maxWidth - animation.value * 400,
child: child: _buildContent(context, ref, serverUrl),
animation.value > 0.1
? FileInfoSheet(item: item, onClose: showInfoSheet)
: const SizedBox.shrink(),
), ),
), // Animated drawer panel - overlays
], if (isWide)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 400,
child: Transform.translate(
offset: Offset((1 - animation.value) * 400, 0),
child: SizedBox(
width: 400,
child: Material(
color:
Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
child: FileInfoSheet(
item: item,
onClose: showInfoSheet,
),
),
),
),
),
],
);
},
); );
}, },
), ),
@@ -187,6 +210,9 @@ class FileDetailScreen extends HookConsumerWidget {
} }
Future<void> _downloadFile(WidgetRef ref) async { Future<void> _downloadFile(WidgetRef ref) async {
final taskId = ref
.read(uploadTasksProvider.notifier)
.addLocalDownloadTask(item);
try { try {
showSnackBar('Downloading file...'); showSnackBar('Downloading file...');
@@ -202,14 +228,34 @@ class FileDetailScreen extends HookConsumerWidget {
'/drive/files/${item.id}', '/drive/files/${item.id}',
filePath, filePath,
queryParameters: {'original': true}, queryParameters: {'original': true},
onReceiveProgress: (count, total) {
if (total > 0) {
ref
.read(uploadTasksProvider.notifier)
.updateDownloadProgress(taskId, count, total);
ref
.read(uploadTasksProvider.notifier)
.updateTransmissionProgress(taskId, count / total);
}
},
); );
await FileSaver.instance.saveFile( await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name, name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath), file: File(filePath),
); );
ref
.read(uploadTasksProvider.notifier)
.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads'); showSnackBar('File saved to downloads');
} catch (e) { } catch (e) {
ref
.read(uploadTasksProvider.notifier)
.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e); showErrorAlert(e);
} }
} }

View File

@@ -226,7 +226,7 @@ class AccountName extends StatelessWidget {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
account.nick, textOverride ?? account.nick,
style: nameStyle, style: nameStyle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,

View File

@@ -1,7 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -266,68 +266,57 @@ class GenericFileContent extends HookConsumerWidget {
} }
return Center( return Center(
child: Container( child: Column(
margin: const EdgeInsets.all(32), mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.all(32), children: [
decoration: BoxDecoration( Icon(
border: Border.all( Symbols.insert_drive_file,
color: Theme.of(context).colorScheme.outline, size: 64,
width: 1, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
borderRadius: BorderRadius.circular(16), const Gap(16),
), Text(
child: Column( item.name,
mainAxisSize: MainAxisSize.min, style: TextStyle(
children: [ fontSize: 20,
Icon( fontWeight: FontWeight.bold,
Symbols.insert_drive_file, color: Theme.of(context).colorScheme.onSurface,
size: 64, ),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
formatFileSize(item.size),
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
const Gap(16), ),
Text( const Gap(24),
item.name, Row(
style: TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 20, children: [
fontWeight: FontWeight.bold, FilledButton.icon(
color: Theme.of(context).colorScheme.onSurface, onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download').tr(),
), ),
textAlign: TextAlign.center, const Gap(16),
), OutlinedButton.icon(
const Gap(8), onPressed: () {
Text( showModalBottomSheet(
formatFileSize(item.size), useRootNavigator: true,
style: TextStyle( context: context,
fontSize: 16, isScrollControlled: true,
color: Theme.of(context).colorScheme.onSurfaceVariant, builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info').tr(),
), ),
), ],
const Gap(24), ),
Row( ],
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download'),
),
const Gap(16),
OutlinedButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info'),
),
],
),
],
),
), ),
); );
} }

View File

@@ -1,10 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
@@ -28,28 +25,12 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
VideoController? _videoController; VideoController? _videoController;
void _openVideo() async { void _openVideo() async {
final url = widget.uri;
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
_player = Player(); _player = Player();
_videoController = VideoController(_player!); _videoController = VideoController(_player!);
String? uri; _player!.open(Media(widget.uri), play: widget.autoplay);
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
talker.info('[MediaPlayer] Miss cache: $url');
final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile(
url,
authHeaders: {'Authorization': 'AtField $token'},
);
uri = url;
} else {
uri = inCacheInfo.file.path;
talker.info('[MediaPlayer] Hit cache: $url');
}
_player!.open(Media(uri), play: widget.autoplay);
} }
@override @override

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:math' as math; 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';
@@ -31,7 +30,6 @@ class UploadOverlay extends HookConsumerWidget {
final isVisibleOverride = useState<bool?>(null); final isVisibleOverride = useState<bool?>(null);
final pendingHide = useState(false); final pendingHide = useState(false);
final hideTimer = useState<Timer?>(null);
final isVisible = final isVisible =
(isVisibleOverride.value ?? activeTasks.isNotEmpty) && (isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
!pendingHide.value; !pendingHide.value;
@@ -53,24 +51,6 @@ class UploadOverlay extends HookConsumerWidget {
return null; return null;
}, [isVisible]); }, [isVisible]);
// Handle hide delay when tasks complete
useEffect(() {
if (activeTasks.isEmpty && (isVisibleOverride.value ?? false) == false) {
// No active tasks and not manually visible (not expanded)
hideTimer.value = Timer(const Duration(seconds: 2), () {
pendingHide.value = true;
});
} else {
// Cancel any pending hide and reset
hideTimer.value?.cancel();
hideTimer.value = null;
pendingHide.value = false;
}
return () {
hideTimer.value?.cancel();
};
}, [activeTasks.length, isVisibleOverride.value]);
if (!isVisible && slideController.status == AnimationStatus.dismissed) { if (!isVisible && slideController.status == AnimationStatus.dismissed) {
// If not visible and animation is complete (back to start), don't show anything // If not visible and animation is complete (back to start), don't show anything
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -86,7 +66,6 @@ class UploadOverlay extends HookConsumerWidget {
position: slideAnimation, position: slideAnimation,
child: _UploadOverlayContent( child: _UploadOverlayContent(
activeTasks: activeTasks, activeTasks: activeTasks,
onVisibilityChanged: (bool? v) => isVisibleOverride.value = v,
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom), ).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
), ),
); );
@@ -95,15 +74,12 @@ class UploadOverlay extends HookConsumerWidget {
class _UploadOverlayContent extends HookConsumerWidget { class _UploadOverlayContent extends HookConsumerWidget {
final List<DriveTask> activeTasks; final List<DriveTask> activeTasks;
final void Function(bool?) onVisibilityChanged;
const _UploadOverlayContent({ const _UploadOverlayContent({required this.activeTasks});
required this.activeTasks,
required this.onVisibilityChanged,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = useState(false);
final animationController = useAnimationController( final animationController = useAnimationController(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
initialValue: 0.0, initialValue: 0.0,
@@ -117,15 +93,12 @@ class _UploadOverlayContent extends HookConsumerWidget {
CurvedAnimation(parent: animationController, curve: Curves.easeInOut), CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
); );
final isExpanded = useState(false);
useEffect(() { useEffect(() {
if (isExpanded.value) { if (isExpanded.value) {
animationController.forward(); animationController.forward();
} else { } else {
animationController.reverse(); animationController.reverse();
} }
onVisibilityChanged.call(isExpanded.value);
return null; return null;
}, [isExpanded.value]); }, [isExpanded.value]);
@@ -349,6 +322,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
IconData _getOverallStatusIcon(List<DriveTask> tasks) { IconData _getOverallStatusIcon(List<DriveTask> tasks) {
if (tasks.isEmpty) return Symbols.upload; if (tasks.isEmpty) return Symbols.upload;
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
final hasInProgress = tasks.any( final hasInProgress = tasks.any(
(task) => task.status == DriveTaskStatus.inProgress, (task) => task.status == DriveTaskStatus.inProgress,
); );
@@ -370,6 +344,9 @@ class _UploadOverlayContent extends HookConsumerWidget {
// Priority order: in progress > pending > paused > failed > completed // Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) { if (hasInProgress) {
if (hasDownload) {
return Symbols.download;
}
return Symbols.upload; return Symbols.upload;
} else if (hasPending) { } else if (hasPending) {
return Symbols.schedule; return Symbols.schedule;
@@ -387,6 +364,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
String _getOverallStatusText(List<DriveTask> tasks) { String _getOverallStatusText(List<DriveTask> tasks) {
if (tasks.isEmpty) return '0 tasks'; if (tasks.isEmpty) return '0 tasks';
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
final hasInProgress = tasks.any( final hasInProgress = tasks.any(
(task) => task.status == DriveTaskStatus.inProgress, (task) => task.status == DriveTaskStatus.inProgress,
); );
@@ -408,7 +386,11 @@ class _UploadOverlayContent extends HookConsumerWidget {
// Priority order: in progress > pending > paused > failed > completed // Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) { if (hasInProgress) {
return '${tasks.length} ${'uploading'.tr()}'; if (hasDownload) {
return '${tasks.length} ${'downloading'.tr()}';
} else {
return '${tasks.length} ${'uploading'.tr()}';
}
} else if (hasPending) { } else if (hasPending) {
return '${tasks.length} ${'pending'.tr()}'; return '${tasks.length} ${'pending'.tr()}';
} else if (hasPaused) { } else if (hasPaused) {
@@ -550,7 +532,10 @@ class _UploadTaskTileState extends State<UploadTaskTile>
color = Theme.of(context).colorScheme.secondary; color = Theme.of(context).colorScheme.secondary;
break; break;
case DriveTaskStatus.inProgress: case DriveTaskStatus.inProgress:
icon = Symbols.upload; icon =
widget.task.type == 'FileDownload'
? Symbols.download
: Symbols.upload;
color = Theme.of(context).colorScheme.primary; color = Theme.of(context).colorScheme.primary;
break; break;
case DriveTaskStatus.paused: case DriveTaskStatus.paused: