✨ Renders file folders in drive
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_list.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$billingUsageHash() => r'58d8bc774868d60781574c85d6b25869a79c57aa';
|
||||
|
||||
/// See also [billingUsage].
|
||||
@ProviderFor(billingUsage)
|
||||
final billingUsageProvider =
|
||||
AutoDisposeFutureProvider<Map<String, dynamic>?>.internal(
|
||||
billingUsage,
|
||||
name: r'billingUsageProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$billingUsageHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BillingUsageRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
||||
String _$billingQuotaHash() => r'4ec5d728e439015800abb2d0d673b5a7329cc654';
|
||||
|
||||
/// See also [billingQuota].
|
||||
@ProviderFor(billingQuota)
|
||||
final billingQuotaProvider =
|
||||
AutoDisposeFutureProvider<Map<String, dynamic>?>.internal(
|
||||
billingQuota,
|
||||
name: r'billingQuotaProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$billingQuotaHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
||||
String _$cloudFileListNotifierHash() =>
|
||||
r'a29adc14e3eede41be05de373785f13439cf9e60';
|
||||
|
||||
/// See also [CloudFileListNotifier].
|
||||
@ProviderFor(CloudFileListNotifier)
|
||||
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||
CloudFileListNotifier,
|
||||
CursorPagingData<SnCloudFileIndex>
|
||||
>.internal(
|
||||
CloudFileListNotifier.new,
|
||||
name: r'cloudFileListNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$cloudFileListNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$CloudFileListNotifier =
|
||||
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFileIndex>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
Reference in New Issue
Block a user