Compare commits

..

11 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
0303ef4a93 💄 Optimize file list again 2025-11-18 00:20:10 +08:00
c2b18ce10b 🐛 Fix file list 2025-11-18 00:04:07 +08:00
0767bb53ce Put clean up recycled files back 2025-11-17 23:53:11 +08:00
b233f9a410 💄 File list loading indicator 2025-11-17 23:10:13 +08:00
256024fb46 💄 Adjust upload overlay auto show and hide logic 2025-11-17 22:57:53 +08:00
4a80aaf24d Unindexed files filter 2025-11-17 22:57:42 +08:00
aafd160c44 🐛 Fix waterfall styling issue 2025-11-17 22:00:51 +08:00
4a800725e3 Zoom image via mosue scroll 2025-11-17 22:00:35 +08:00
9 changed files with 609 additions and 273 deletions

View File

@@ -11,12 +11,18 @@ part 'file_list.g.dart';
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<FileListItem> {
String _currentPath = '/';
String? _poolId;
void setPath(String path) {
_currentPath = path;
ref.invalidateSelf();
}
void setPool(String? poolId) {
_poolId = poolId;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -26,9 +32,15 @@ class CloudFileListNotifier extends _$CloudFileListNotifier
}) async {
final client = ref.read(apiClientProvider);
final queryParameters = <String, String>{'path': _currentPath};
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
final response = await client.get(
'/drive/index/browse',
queryParameters: {'path': _currentPath},
queryParameters: queryParameters,
);
final List<String> folders =
@@ -58,6 +70,19 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
@riverpod
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
with CursorPagingNotifierMixin<FileListItem> {
String? _poolId;
bool _recycled = false;
void setPool(String? poolId) {
_poolId = poolId;
ref.invalidateSelf();
}
void setRecycled(bool recycled) {
_recycled = recycled;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -70,9 +95,22 @@ class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
const take = 50; // Default page size
final queryParameters = <String, String>{
'take': take.toString(),
'offset': offset.toString(),
};
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_recycled) {
queryParameters['recycled'] = _recycled.toString();
}
final response = await client.get(
'/drive/index/unindexed',
queryParameters: {'take': take.toString(), 'offset': offset.toString()},
queryParameters: queryParameters,
);
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;

View File

@@ -258,6 +258,24 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
}).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) {
state = state.where((task) => task.taskId != taskId).toList();
}
@@ -291,6 +309,27 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
.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
void dispose() {
_websocketSubscription?.cancel();

View File

@@ -10,6 +10,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.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/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
@@ -76,7 +78,7 @@ class FileDetailScreen extends HookConsumerWidget {
}, [animationController]);
return AppScaffold(
isNoBackground: true,
isNoBackground: false,
appBar: AppBar(
elevation: 0,
leading: IconButton(
@@ -86,28 +88,49 @@ class FileDetailScreen extends HookConsumerWidget {
title: Text(item.name.isEmpty ? 'File Details' : item.name),
actions: _buildAppBarActions(context, ref, showInfoSheet),
),
body: AnimatedBuilder(
body: LayoutBuilder(
builder: (context, constraints) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Row(
return Stack(
children: [
// Main content area
Expanded(child: _buildContent(context, ref, serverUrl)),
// Animated drawer panel
// Main content area - resizes with animation
Positioned(
left: 0,
top: 0,
bottom: 0,
width: constraints.maxWidth - animation.value * 400,
child: _buildContent(context, ref, serverUrl),
),
// Animated drawer panel - overlays
if (isWide)
SizedBox(
height: double.infinity,
width: animation.value * 400, // Max width of 400px
child: Container(
child:
animation.value > 0.1
? FileInfoSheet(item: item, onClose: showInfoSheet)
: const SizedBox.shrink(),
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 {
final taskId = ref
.read(uploadTasksProvider.notifier)
.addLocalDownloadTask(item);
try {
showSnackBar('Downloading file...');
@@ -202,14 +228,34 @@ class FileDetailScreen extends HookConsumerWidget {
'/drive/files/${item.id}',
filePath,
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(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
ref
.read(uploadTasksProvider.notifier)
.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads');
} catch (e) {
ref
.read(uploadTasksProvider.notifier)
.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e);
}
}

View File

@@ -1,10 +1,12 @@
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
@@ -23,6 +25,7 @@ class FileListScreen extends HookConsumerWidget {
// Path navigation state
final currentPath = useState<String>('/');
final mode = useState<FileListMode>(FileListMode.normal);
final selectedPool = useState<SnFilePool?>(null);
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
@@ -32,7 +35,7 @@ class FileListScreen extends HookConsumerWidget {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text('Files'),
title: Text('files').tr(),
leading: const PageBackButton(),
actions: [
IconButton(
@@ -55,8 +58,13 @@ class FileListScreen extends HookConsumerWidget {
usage: usage,
quota: quota,
currentPath: currentPath,
selectedPool: selectedPool,
onPickAndUpload:
() => _pickAndUploadFile(ref, currentPath.value),
() => _pickAndUploadFile(
ref,
currentPath.value,
selectedPool.value?.id,
),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
@@ -70,7 +78,11 @@ class FileListScreen extends HookConsumerWidget {
);
}
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async {
Future<void> _pickAndUploadFile(
WidgetRef ref,
String currentPath,
String? poolId,
) async {
try {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
@@ -92,6 +104,7 @@ class FileListScreen extends HookConsumerWidget {
fileData: universalFile,
ref: ref,
path: currentPath,
poolId: poolId,
onProgress: (progress, _) {
// Progress is handled by the upload tasks system
if (progress != null) {

View File

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

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -89,6 +90,25 @@ class ImageFileContent extends HookConsumerWidget {
return Stack(
children: [
Positioned.fill(
child: Listener(
onPointerSignal: (pointerSignal) {
try {
// Handle mouse wheel zoom - cast to dynamic to access scrollDelta
final delta =
(pointerSignal as dynamic).scrollDelta.dy as double?;
if (delta != null && delta != 0) {
final currentScale = photoViewController.scale ?? 1.0;
// Adjust scale based on scroll direction (invert for natural zoom)
final newScale =
delta > 0 ? currentScale * 0.9 : currentScale * 1.1;
// Clamp scale to reasonable bounds
final clampedScale = newScale.clamp(0.1, 10.0);
photoViewController.scale = clampedScale;
}
} catch (e) {
// Ignore non-scroll events
}
},
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
@@ -104,6 +124,7 @@ class ImageFileContent extends HookConsumerWidget {
filterQuality: FilterQuality.high,
),
),
),
// Controls overlay
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
@@ -245,16 +266,6 @@ class GenericFileContent extends HookConsumerWidget {
}
return Center(
child: Container(
margin: const EdgeInsets.all(32),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -288,7 +299,7 @@ class GenericFileContent extends HookConsumerWidget {
FilledButton.icon(
onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download'),
label: Text('download').tr(),
),
const Gap(16),
OutlinedButton.icon(
@@ -301,13 +312,12 @@ class GenericFileContent extends HookConsumerWidget {
);
},
icon: const Icon(Symbols.info),
label: Text('info'),
label: Text('info').tr(),
),
],
),
],
),
),
);
}
}

View File

@@ -1,10 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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_video/media_kit_video.dart';
@@ -28,28 +25,12 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
VideoController? _videoController;
void _openVideo() async {
final url = widget.uri;
MediaKit.ensureInitialized();
_player = Player();
_videoController = VideoController(_player!);
String? uri;
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);
_player!.open(Media(widget.uri), play: widget.autoplay);
}
@override

View File

@@ -1,4 +1,5 @@
import 'package:desktop_drop/desktop_drop.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -8,7 +9,9 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file_list_item.dart';
import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/services/responsive.dart';
@@ -29,6 +32,7 @@ class FileListView extends HookConsumerWidget {
final Map<String, dynamic>? usage;
final Map<String, dynamic>? quota;
final ValueNotifier<String> currentPath;
final ValueNotifier<SnFilePool?> selectedPool;
final VoidCallback onPickAndUpload;
final Function(BuildContext, ValueNotifier<String>) onShowCreateDirectory;
final ValueNotifier<FileListMode> mode;
@@ -38,6 +42,7 @@ class FileListView extends HookConsumerWidget {
required this.usage,
required this.quota,
required this.currentPath,
required this.selectedPool,
required this.onPickAndUpload,
required this.onShowCreateDirectory,
required this.mode,
@@ -59,6 +64,14 @@ class FileListView extends HookConsumerWidget {
if (usage == null) return const SizedBox.shrink();
final isRefreshing = ref.watch(
mode.value == FileListMode.normal
? cloudFileListNotifierProvider.select((value) => value.isLoading)
: unindexedFileListNotifierProvider.select(
(value) => value.isLoading,
),
);
final bodyWidget = switch (mode.value) {
FileListMode.unindexed => PagingHelperSliverView(
provider: unindexedFileListNotifierProvider,
@@ -101,6 +114,152 @@ class FileListView extends HookConsumerWidget {
),
};
final unindexedNotifier = ref.read(
unindexedFileListNotifierProvider.notifier,
);
final cloudNotifier = ref.read(cloudFileListNotifierProvider.notifier);
final recycled = useState<bool>(false);
final poolsAsync = ref.watch(poolsProvider);
useEffect(() {
// Sync pool when mode or selectedPool changes
if (mode.value == FileListMode.unindexed) {
unindexedNotifier.setPool(selectedPool.value?.id);
} else {
cloudNotifier.setPool(selectedPool.value?.id);
}
return null;
}, [selectedPool.value, mode.value]);
final poolDropdownItems = poolsAsync.when(
data:
(pools) => [
const DropdownMenuItem<SnFilePool>(
value: null,
child: Text('All Pools', style: TextStyle(fontSize: 14)),
),
...pools.map(
(p) => DropdownMenuItem<SnFilePool>(
value: p,
child: Text(p.name, style: const TextStyle(fontSize: 14)),
),
),
],
loading: () => const <DropdownMenuItem<SnFilePool>>[],
error: (err, stack) => const <DropdownMenuItem<SnFilePool>>[],
);
final poolDropdown = DropdownButtonHideUnderline(
child: DropdownButton2<SnFilePool>(
value: selectedPool.value,
items: poolDropdownItems,
onChanged:
isRefreshing
? null
: (value) {
selectedPool.value = value;
if (mode.value == FileListMode.unindexed) {
unindexedNotifier.setPool(value?.id);
} else {
cloudNotifier.setPool(value?.id);
}
},
customButton: Container(
height: 28,
width: 200,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(ref.context).colorScheme.outline,
),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 6,
children: [
const Icon(Symbols.pool, size: 16),
Flexible(
child: Text(
selectedPool.value?.name ?? 'All files',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(12),
),
],
).height(24),
),
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.zero,
height: 28,
width: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
dropdownStyleData: const DropdownStyleData(maxHeight: 200),
),
);
late Widget pathContent;
if (mode.value == FileListMode.unindexed) {
pathContent = const Text(
'Unindexed Files',
style: TextStyle(fontWeight: FontWeight.bold),
);
} else if (currentPath.value == '/') {
pathContent = const Text(
'Root Directory',
style: TextStyle(fontWeight: FontWeight.bold),
);
} else {
final pathParts =
currentPath.value
.split('/')
.where((part) => part.isNotEmpty)
.toList();
final breadcrumbs = <Widget>[];
// Add root
breadcrumbs.add(
InkWell(
onTap: () => currentPath.value = '/',
child: const Text('Root'),
),
);
// Add path parts
String currentPathBuilder = '';
for (int i = 0; i < pathParts.length; i++) {
currentPathBuilder += '/${pathParts[i]}';
final path = currentPathBuilder;
breadcrumbs.add(const Text(' / '));
if (i == pathParts.length - 1) {
// Current directory
breadcrumbs.add(
Text(
pathParts[i],
style: const TextStyle(fontWeight: FontWeight.bold),
),
);
} else {
// Clickable parent directory
breadcrumbs.add(
InkWell(
onTap: () => currentPath.value = path,
child: Text(pathParts[i]),
),
);
}
}
pathContent = Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: breadcrumbs,
);
}
return DropTarget(
onDragDone: (details) async {
dragging.value = false;
@@ -115,7 +274,8 @@ class FileListView extends HookConsumerWidget {
final completer = FileUploader.createCloudFile(
fileData: universalFile,
ref: ref,
path: currentPath.value,
path: mode.value == FileListMode.normal ? currentPath.value : null,
poolId: selectedPool.value?.id,
onProgress: (progress, _) {
// Progress is handled by the upload tasks system
if (progress != null) {
@@ -147,9 +307,130 @@ class FileListView extends HookConsumerWidget {
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(12),
poolDropdown.padding(horizontal: 16),
const Gap(6),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: Icon(
mode.value == FileListMode.unindexed
? Symbols.inventory_2
: currentPath.value != '/'
? Symbols.arrow_back
: Symbols.folder,
),
onPressed:
isRefreshing
? null
: () {
if (mode.value == FileListMode.unindexed) {
mode.value = FileListMode.normal;
currentPath.value = '/';
} else {
final pathParts =
currentPath.value
.split('/')
.where((part) => part.isNotEmpty)
.toList();
if (pathParts.isNotEmpty) {
pathParts.removeLast();
currentPath.value =
pathParts.isEmpty
? '/'
: '/${pathParts.join('/')}';
}
}
},
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
const Gap(8),
_buildPathNavigation(ref, currentPath),
Expanded(
child: AbsorbPointer(
absorbing: isRefreshing,
child: pathContent,
),
),
IconButton(
icon: Icon(
viewMode.value == FileListViewMode.list
? Symbols.view_module
: Symbols.list,
),
onPressed:
() =>
viewMode.value =
viewMode.value == FileListViewMode.list
? FileListViewMode.waterfall
: FileListViewMode.list,
tooltip:
viewMode.value == FileListViewMode.list
? 'Switch to Waterfall View'
: 'Switch to List View',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
if (mode.value == FileListMode.normal)
IconButton(
icon: const Icon(Symbols.create_new_folder),
onPressed:
() =>
onShowCreateDirectory(ref.context, currentPath),
tooltip: 'Create Directory',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
if (mode.value == FileListMode.unindexed)
IconButton(
icon: Icon(
recycled.value
? Symbols.delete_forever
: Symbols.restore_from_trash,
),
onPressed: () {
recycled.value = !recycled.value;
unindexedNotifier.setRecycled(recycled.value);
},
tooltip:
recycled.value
? 'Show Active Files'
: 'Show Recycle Bin',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
IconButton(
icon: const Icon(Symbols.upload_file),
onPressed: onPickAndUpload,
tooltip: 'Upload File',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
],
),
),
).padding(horizontal: 8),
if (mode.value == FileListMode.unindexed && recycled.value)
_buildClearRecycledButton(ref).padding(horizontal: 8),
if (isRefreshing)
const LinearProgressIndicator(
minHeight: 4,
).padding(horizontal: 16, top: 6, bottom: 4),
const Gap(8),
if (mode.value == FileListMode.normal && currentPath.value == '/')
_buildUnindexedFilesEntry(ref).padding(bottom: 12),
@@ -302,155 +583,6 @@ class FileListView extends HookConsumerWidget {
};
}
Widget _buildPathNavigation(
WidgetRef ref,
ValueNotifier<String> currentPath,
) {
Widget pathContent;
if (mode.value == FileListMode.unindexed) {
pathContent = Row(
children: [
Text(
'Unindexed Files',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
);
} else if (currentPath.value == '/') {
pathContent = Text(
'Root Directory',
style: TextStyle(fontWeight: FontWeight.bold),
);
} else {
final pathParts =
currentPath.value
.split('/')
.where((part) => part.isNotEmpty)
.toList();
final breadcrumbs = <Widget>[];
// Add root
breadcrumbs.add(
InkWell(onTap: () => currentPath.value = '/', child: Text('Root')),
);
// Add path parts
String currentPathBuilder = '';
for (int i = 0; i < pathParts.length; i++) {
currentPathBuilder += '/${pathParts[i]}';
final path = currentPathBuilder;
breadcrumbs.add(const Text(' / '));
if (i == pathParts.length - 1) {
// Current directory
breadcrumbs.add(
Text(pathParts[i], style: TextStyle(fontWeight: FontWeight.bold)),
);
} else {
// Clickable parent directory
breadcrumbs.add(
InkWell(
onTap: () => currentPath.value = path,
child: Text(pathParts[i]),
),
);
}
}
pathContent = Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: breadcrumbs,
);
}
return SizedBox(
height: 64,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: Icon(
mode.value == FileListMode.unindexed
? Symbols.inventory_2
: currentPath.value != '/'
? Symbols.arrow_back
: Symbols.folder,
),
onPressed: () {
if (mode.value == FileListMode.unindexed) {
mode.value = FileListMode.normal;
currentPath.value = '/';
} else {
final pathParts =
currentPath.value
.split('/')
.where((part) => part.isNotEmpty)
.toList();
if (pathParts.isNotEmpty) {
pathParts.removeLast();
currentPath.value =
pathParts.isEmpty ? '/' : '/${pathParts.join('/')}';
}
}
},
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
const Gap(8),
Expanded(child: pathContent),
IconButton(
icon: Icon(
viewMode.value == FileListViewMode.list
? Symbols.view_module
: Symbols.list,
),
onPressed:
() =>
viewMode.value =
viewMode.value == FileListViewMode.list
? FileListViewMode.waterfall
: FileListViewMode.list,
tooltip:
viewMode.value == FileListViewMode.list
? 'Switch to Waterfall View'
: 'Switch to List View',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
if (mode.value == FileListMode.normal) ...[
IconButton(
icon: const Icon(Symbols.create_new_folder),
onPressed:
() => onShowCreateDirectory(ref.context, currentPath),
tooltip: 'Create Directory',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
IconButton(
icon: const Icon(Symbols.upload_file),
onPressed: onPickAndUpload,
tooltip: 'Upload File',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
],
],
),
),
).padding(horizontal: 8),
);
}
Widget _buildUnindexedFilesEntry(WidgetRef ref) {
return Container(
decoration: BoxDecoration(
@@ -485,7 +617,10 @@ class FileListView extends HookConsumerWidget {
ValueNotifier<String> currentPath,
) {
return Card(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 16),
margin:
viewMode.value == FileListViewMode.waterfall
? const EdgeInsets.fromLTRB(0, 0, 0, 16)
: const EdgeInsets.fromLTRB(12, 0, 12, 16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Column(
@@ -922,7 +1057,10 @@ class FileListView extends HookConsumerWidget {
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
return Card(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
margin:
viewMode.value == FileListViewMode.waterfall
? EdgeInsets.zero
: const EdgeInsets.fromLTRB(12, 0, 12, 0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Column(
@@ -954,4 +1092,59 @@ class FileListView extends HookConsumerWidget {
),
);
}
Widget _buildClearRecycledButton(WidgetRef ref) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
spacing: 16,
children: [
const Icon(Symbols.recycling).padding(horizontal: 2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Clear All Recycled Files').bold(),
const Text(
'Permanently delete all marked recycled files to free up space.',
).fontSize(13),
],
),
),
ElevatedButton.icon(
icon: const Icon(Symbols.delete_forever),
label: const Text('Clear'),
onPressed: () async {
final confirmed = await showConfirmAlert(
'Are you sure you want to clear all recycled files?',
'Clear Recycled Files',
);
if (!confirmed) return;
if (ref.context.mounted) {
showLoadingModal(ref.context);
}
try {
final client = ref.read(apiClientProvider);
final response = await client.delete(
'/drive/files/me/recycle',
);
final count = response.data['count'] as int? ?? 0;
showSnackBar('Cleared $count recycled files.');
ref.invalidate(unindexedFileListNotifierProvider);
} catch (e) {
showSnackBar('Failed to clear recycled files.');
} finally {
if (ref.context.mounted) {
hideLoadingModal(ref.context);
}
}
},
),
],
),
),
);
}
}

View File

@@ -1,5 +1,4 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
@@ -29,7 +28,11 @@ class UploadOverlay extends HookConsumerWidget {
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Newest first
final isVisible = activeTasks.isNotEmpty;
final isVisibleOverride = useState<bool?>(null);
final pendingHide = useState(false);
final isVisible =
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
!pendingHide.value;
final slideController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
@@ -256,6 +259,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
ref
.read(uploadTasksProvider.notifier)
.clearCompletedTasks();
isExpanded.value = false;
},
tileColor:
Theme.of(
@@ -318,6 +322,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
IconData _getOverallStatusIcon(List<DriveTask> tasks) {
if (tasks.isEmpty) return Symbols.upload;
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
final hasInProgress = tasks.any(
(task) => task.status == DriveTaskStatus.inProgress,
);
@@ -339,6 +344,9 @@ class _UploadOverlayContent extends HookConsumerWidget {
// Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) {
if (hasDownload) {
return Symbols.download;
}
return Symbols.upload;
} else if (hasPending) {
return Symbols.schedule;
@@ -356,6 +364,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
String _getOverallStatusText(List<DriveTask> tasks) {
if (tasks.isEmpty) return '0 tasks';
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
final hasInProgress = tasks.any(
(task) => task.status == DriveTaskStatus.inProgress,
);
@@ -377,7 +386,11 @@ class _UploadOverlayContent extends HookConsumerWidget {
// Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) {
if (hasDownload) {
return '${tasks.length} ${'downloading'.tr()}';
} else {
return '${tasks.length} ${'uploading'.tr()}';
}
} else if (hasPending) {
return '${tasks.length} ${'pending'.tr()}';
} else if (hasPaused) {
@@ -519,7 +532,10 @@ class _UploadTaskTileState extends State<UploadTaskTile>
color = Theme.of(context).colorScheme.secondary;
break;
case DriveTaskStatus.inProgress:
icon = Symbols.upload;
icon =
widget.task.type == 'FileDownload'
? Symbols.download
: Symbols.upload;
color = Theme.of(context).colorScheme.primary;
break;
case DriveTaskStatus.paused: