Renders file folders in drive

This commit is contained in:
2025-11-13 01:31:58 +08:00
parent c779c7523c
commit 66f283d6e8
6 changed files with 790 additions and 246 deletions

View File

@@ -1,74 +1,18 @@
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/pods/network.dart';
import 'package:island/pods/file_list.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/file_list_view.dart';
import 'package:island/widgets/usage_overview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'file_list.g.dart';
@riverpod
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<SnCloudFileIndex> {
String _currentPath = '/';
void setPath(String path) {
_currentPath = path;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<SnCloudFileIndex>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<SnCloudFileIndex>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/drive/index/browse',
queryParameters: {'path': _currentPath},
);
final List<SnCloudFileIndex> items =
(response.data['files'] as List)
.map((e) => SnCloudFileIndex.fromJson(e as Map<String, dynamic>))
.toList();
// The new API returns all files in the path, no pagination
return CursorPagingData(items: items, hasMore: false, nextCursor: null);
}
}
@riverpod
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/usage');
return response.data;
}
@riverpod
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/quota');
return response.data;
}
class FileListScreen extends HookConsumerWidget {
const FileListScreen({super.key});
@@ -81,24 +25,12 @@ class FileListScreen extends HookConsumerWidget {
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
// Update notifier path when state changes
useEffect(() {
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
notifier.setPath(currentPath.value);
return null;
}, [currentPath.value]);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text('Files'),
leading: const PageBackButton(),
actions: [
IconButton(
icon: const Icon(Symbols.upload_file),
onPressed: () => _pickAndUploadFile(ref, currentPath.value),
tooltip: 'Upload File',
),
IconButton(
icon: const Icon(Symbols.bar_chart),
onPressed:
@@ -114,7 +46,15 @@ class FileListScreen extends HookConsumerWidget {
body: usageAsync.when(
data:
(usage) => quotaAsync.when(
data: (quota) => _buildQuotaUI(usage, quota, ref, currentPath),
data:
(quota) => FileListView(
usage: usage,
quota: quota,
currentPath: currentPath,
onPickAndUpload:
() => _pickAndUploadFile(ref, currentPath.value),
onShowCreateDirectory: _showCreateDirectoryDialog,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),
),
@@ -124,178 +64,6 @@ class FileListScreen extends HookConsumerWidget {
);
}
Widget _buildQuotaUI(
Map<String, dynamic>? usage,
Map<String, dynamic>? quota,
WidgetRef ref,
ValueNotifier<String> currentPath,
) {
if (usage == null) return const SizedBox.shrink();
return CustomScrollView(
slivers: [
const SliverGap(8),
SliverToBoxAdapter(child: _buildPathNavigation(ref, currentPath)),
const SliverGap(8),
PagingHelperSliverView(
provider: cloudFileListNotifierProvider,
futureRefreshable: cloudFileListNotifierProvider.future,
notifierRefreshable: cloudFileListNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = data.items[index];
final file = item.file;
final itemType = file.mimeType?.split('/').firstOrNull;
return ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: switch (itemType) {
'image' => CloudImageWidget(file: file),
'audio' =>
const Icon(Symbols.audio_file, fill: 1).center(),
'video' =>
const Icon(Symbols.video_file, fill: 1).center(),
_ =>
const Icon(Symbols.body_system, fill: 1).center(),
},
),
),
title:
file.name.isEmpty
? Text('untitled').tr().italic()
: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(formatFileSize(file.size)),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: file),
);
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) showLoadingModal(context);
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/index/remove/${item.id}');
ref.invalidate(cloudFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
);
},
),
),
],
);
}
Widget _buildPathNavigation(
WidgetRef ref,
ValueNotifier<String> currentPath,
) {
if (currentPath.value == '/') {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Symbols.folder),
const Gap(8),
Text(
'Root Directory',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
).padding(horizontal: 8);
}
final pathParts =
currentPath.value.split('/').where((part) => part.isNotEmpty).toList();
final breadcrumbs = <Widget>[];
// Add root
breadcrumbs.add(
InkWell(
onTap: () => currentPath.value = '/',
child: Text(
'Root',
style: TextStyle(color: Theme.of(ref.context).primaryColor),
),
),
);
// 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],
style: TextStyle(color: Theme.of(ref.context).primaryColor),
),
),
);
}
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Symbols.folder),
const Gap(8),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: breadcrumbs,
),
),
],
),
),
).padding(horizontal: 8);
}
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async {
try {
final result = await FilePicker.platform.pickFiles(
@@ -345,6 +113,75 @@ class FileListScreen extends HookConsumerWidget {
}
}
Future<void> _showCreateDirectoryDialog(
BuildContext context,
ValueNotifier<String> currentPath,
) async {
final controller = TextEditingController(text: currentPath.value);
String? newPath;
void handleChangeDirectory(BuildContext context) {
newPath = controller.text.trim();
if (newPath!.isNotEmpty) {
// Normalize the path
String fullPath = newPath!;
// Ensure it starts with /
if (!fullPath.startsWith('/')) {
fullPath = '/$fullPath';
}
// Remove double slashes and normalize
fullPath = fullPath.replaceAll(RegExp(r'/+'), '/');
currentPath.value = fullPath;
Navigator.of(context).pop();
}
}
await showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Navigate to Directory'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(8),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Directory path',
hintText: 'e.g., documents, projects/my-app',
helperText:
'Enter a directory path. The directory will be created when you upload files to it.',
helperMaxLines: 3,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onSubmitted: (_) {
handleChangeDirectory(context);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton.icon(
onPressed: () => handleChangeDirectory(context),
label: const Text('Go to Directory'),
icon: const Icon(Symbols.arrow_right_alt),
),
],
),
);
}
void _showUsageSheet(
BuildContext context,
Map<String, dynamic>? usage,