From 66f283d6e8054340dc079c3f7fb55659177c3ebd Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 13 Nov 2025 01:31:58 +0800 Subject: [PATCH] :sparkles: Renders file folders in drive --- lib/models/file_list_item.dart | 10 + lib/models/file_list_item.freezed.dart | 315 ++++++++++++++++++ lib/pods/file_list.dart | 63 ++++ lib/{screens/files => pods}/file_list.g.dart | 6 +- lib/screens/files/file_list.dart | 323 +++++-------------- lib/widgets/file_list_view.dart | 319 ++++++++++++++++++ 6 files changed, 790 insertions(+), 246 deletions(-) create mode 100644 lib/models/file_list_item.dart create mode 100644 lib/models/file_list_item.freezed.dart create mode 100644 lib/pods/file_list.dart rename lib/{screens/files => pods}/file_list.g.dart (93%) create mode 100644 lib/widgets/file_list_view.dart diff --git a/lib/models/file_list_item.dart b/lib/models/file_list_item.dart new file mode 100644 index 00000000..7e39e69a --- /dev/null +++ b/lib/models/file_list_item.dart @@ -0,0 +1,10 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/models/file.dart'; + +part 'file_list_item.freezed.dart'; + +@freezed +sealed class FileListItem with _$FileListItem { + const factory FileListItem.file(SnCloudFileIndex fileIndex) = FileItem; + const factory FileListItem.folder(String name) = FolderItem; +} diff --git a/lib/models/file_list_item.freezed.dart b/lib/models/file_list_item.freezed.dart new file mode 100644 index 00000000..85415c65 --- /dev/null +++ b/lib/models/file_list_item.freezed.dart @@ -0,0 +1,315 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'file_list_item.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$FileListItem { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FileListItem); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'FileListItem()'; +} + + +} + +/// @nodoc +class $FileListItemCopyWith<$Res> { +$FileListItemCopyWith(FileListItem _, $Res Function(FileListItem) __); +} + + +/// Adds pattern-matching-related methods to [FileListItem]. +extension FileListItemPatterns on FileListItem { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( FileItem value)? file,TResult Function( FolderItem value)? folder,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case FileItem() when file != null: +return file(_that);case FolderItem() when folder != null: +return folder(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( FileItem value) file,required TResult Function( FolderItem value) folder,}){ +final _that = this; +switch (_that) { +case FileItem(): +return file(_that);case FolderItem(): +return folder(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( FileItem value)? file,TResult? Function( FolderItem value)? folder,}){ +final _that = this; +switch (_that) { +case FileItem() when file != null: +return file(_that);case FolderItem() when folder != null: +return folder(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( SnCloudFileIndex fileIndex)? file,TResult Function( String name)? folder,required TResult orElse(),}) {final _that = this; +switch (_that) { +case FileItem() when file != null: +return file(_that.fileIndex);case FolderItem() when folder != null: +return folder(_that.name);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( SnCloudFileIndex fileIndex) file,required TResult Function( String name) folder,}) {final _that = this; +switch (_that) { +case FileItem(): +return file(_that.fileIndex);case FolderItem(): +return folder(_that.name);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( SnCloudFileIndex fileIndex)? file,TResult? Function( String name)? folder,}) {final _that = this; +switch (_that) { +case FileItem() when file != null: +return file(_that.fileIndex);case FolderItem() when folder != null: +return folder(_that.name);case _: + return null; + +} +} + +} + +/// @nodoc + + +class FileItem implements FileListItem { + const FileItem(this.fileIndex); + + + final SnCloudFileIndex fileIndex; + +/// Create a copy of FileListItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FileItemCopyWith get copyWith => _$FileItemCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FileItem&&(identical(other.fileIndex, fileIndex) || other.fileIndex == fileIndex)); +} + + +@override +int get hashCode => Object.hash(runtimeType,fileIndex); + +@override +String toString() { + return 'FileListItem.file(fileIndex: $fileIndex)'; +} + + +} + +/// @nodoc +abstract mixin class $FileItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> { + factory $FileItemCopyWith(FileItem value, $Res Function(FileItem) _then) = _$FileItemCopyWithImpl; +@useResult +$Res call({ + SnCloudFileIndex fileIndex +}); + + +$SnCloudFileIndexCopyWith<$Res> get fileIndex; + +} +/// @nodoc +class _$FileItemCopyWithImpl<$Res> + implements $FileItemCopyWith<$Res> { + _$FileItemCopyWithImpl(this._self, this._then); + + final FileItem _self; + final $Res Function(FileItem) _then; + +/// Create a copy of FileListItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? fileIndex = null,}) { + return _then(FileItem( +null == fileIndex ? _self.fileIndex : fileIndex // ignore: cast_nullable_to_non_nullable +as SnCloudFileIndex, + )); +} + +/// Create a copy of FileListItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileIndexCopyWith<$Res> get fileIndex { + + return $SnCloudFileIndexCopyWith<$Res>(_self.fileIndex, (value) { + return _then(_self.copyWith(fileIndex: value)); + }); +} +} + +/// @nodoc + + +class FolderItem implements FileListItem { + const FolderItem(this.name); + + + final String name; + +/// Create a copy of FileListItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FolderItemCopyWith get copyWith => _$FolderItemCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FolderItem&&(identical(other.name, name) || other.name == name)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name); + +@override +String toString() { + return 'FileListItem.folder(name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class $FolderItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> { + factory $FolderItemCopyWith(FolderItem value, $Res Function(FolderItem) _then) = _$FolderItemCopyWithImpl; +@useResult +$Res call({ + String name +}); + + + + +} +/// @nodoc +class _$FolderItemCopyWithImpl<$Res> + implements $FolderItemCopyWith<$Res> { + _$FolderItemCopyWithImpl(this._self, this._then); + + final FolderItem _self; + final $Res Function(FolderItem) _then; + +/// Create a copy of FileListItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? name = null,}) { + return _then(FolderItem( +null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/pods/file_list.dart b/lib/pods/file_list.dart new file mode 100644 index 00000000..f89eede0 --- /dev/null +++ b/lib/pods/file_list.dart @@ -0,0 +1,63 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/file.dart'; +import 'package:island/models/file_list_item.dart'; +import 'package:island/pods/network.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; + +part 'file_list.g.dart'; + +@riverpod +class CloudFileListNotifier extends _$CloudFileListNotifier + with CursorPagingNotifierMixin { + String _currentPath = '/'; + + void setPath(String path) { + _currentPath = path; + ref.invalidateSelf(); + } + + @override + Future> build() => fetch(cursor: null); + + @override + Future> fetch({ + required String? cursor, + }) async { + final client = ref.read(apiClientProvider); + + final response = await client.get( + '/drive/index/browse', + queryParameters: {'path': _currentPath}, + ); + + final List folders = + (response.data['folders'] as List).cast(); + final List files = + (response.data['files'] as List) + .map((e) => SnCloudFileIndex.fromJson(e as Map)) + .toList(); + + final List items = [ + ...folders.map((folder) => FileListItem.folder(folder)), + ...files.map((file) => FileListItem.file(file)), + ]; + + // The new API returns all files in the path, no pagination + return CursorPagingData(items: items, hasMore: false, nextCursor: null); + } +} + +@riverpod +Future?> billingUsage(Ref ref) async { + final client = ref.read(apiClientProvider); + final response = await client.get('/drive/billing/usage'); + return response.data; +} + +@riverpod +Future?> billingQuota(Ref ref) async { + final client = ref.read(apiClientProvider); + final response = await client.get('/drive/billing/quota'); + return response.data; +} diff --git a/lib/screens/files/file_list.g.dart b/lib/pods/file_list.g.dart similarity index 93% rename from lib/screens/files/file_list.g.dart rename to lib/pods/file_list.g.dart index 29e8b67c..1b752c4e 100644 --- a/lib/screens/files/file_list.g.dart +++ b/lib/pods/file_list.g.dart @@ -45,13 +45,13 @@ final billingQuotaProvider = // ignore: unused_element typedef BillingQuotaRef = AutoDisposeFutureProviderRef?>; String _$cloudFileListNotifierHash() => - r'a29adc14e3eede41be05de373785f13439cf9e60'; + r'44c17a8ef959bbef5d07132603a722f76d39b9e9'; /// See also [CloudFileListNotifier]. @ProviderFor(CloudFileListNotifier) final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< CloudFileListNotifier, - CursorPagingData + CursorPagingData >.internal( CloudFileListNotifier.new, name: r'cloudFileListNotifierProvider', @@ -64,6 +64,6 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< ); typedef _$CloudFileListNotifier = - AutoDisposeAsyncNotifier>; + AutoDisposeAsyncNotifier>; // 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 diff --git a/lib/screens/files/file_list.dart b/lib/screens/files/file_list.dart index 50226a60..ce4c1e5a 100644 --- a/lib/screens/files/file_list.dart +++ b/lib/screens/files/file_list.dart @@ -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 { - String _currentPath = '/'; - - void setPath(String path) { - _currentPath = path; - ref.invalidateSelf(); - } - - @override - Future> build() => fetch(cursor: null); - - @override - Future> fetch({ - required String? cursor, - }) async { - final client = ref.read(apiClientProvider); - - final response = await client.get( - '/drive/index/browse', - queryParameters: {'path': _currentPath}, - ); - - final List items = - (response.data['files'] as List) - .map((e) => SnCloudFileIndex.fromJson(e as Map)) - .toList(); - - // The new API returns all files in the path, no pagination - return CursorPagingData(items: items, hasMore: false, nextCursor: null); - } -} - -@riverpod -Future?> billingUsage(Ref ref) async { - final client = ref.read(apiClientProvider); - final response = await client.get('/drive/billing/usage'); - return response.data; -} - -@riverpod -Future?> 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? usage, - Map? quota, - WidgetRef ref, - ValueNotifier 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 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 = []; - - // 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 _pickAndUploadFile(WidgetRef ref, String currentPath) async { try { final result = await FilePicker.platform.pickFiles( @@ -345,6 +113,75 @@ class FileListScreen extends HookConsumerWidget { } } + Future _showCreateDirectoryDialog( + BuildContext context, + ValueNotifier 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? usage, diff --git a/lib/widgets/file_list_view.dart b/lib/widgets/file_list_view.dart new file mode 100644 index 00000000..1e19ae61 --- /dev/null +++ b/lib/widgets/file_list_view.dart @@ -0,0 +1,319 @@ +import 'package:easy_localization/easy_localization.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_list_item.dart'; +import 'package:island/pods/file_list.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/utils/format.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/file_info_sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class FileListView extends HookConsumerWidget { + final Map? usage; + final Map? quota; + final ValueNotifier currentPath; + final VoidCallback onPickAndUpload; + final Function(BuildContext, ValueNotifier) onShowCreateDirectory; + + const FileListView({ + required this.usage, + required this.quota, + required this.currentPath, + required this.onPickAndUpload, + required this.onShowCreateDirectory, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useEffect(() { + final notifier = ref.read(cloudFileListNotifierProvider.notifier); + notifier.setPath(currentPath.value); + return null; + }, [currentPath.value]); + + 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) => + data.items.isEmpty + ? SliverToBoxAdapter( + child: _buildEmptyDirectoryHint(ref, currentPath), + ) + : SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + + final item = data.items[index]; + return item.map( + file: (fileItem) { + final file = fileItem.fileIndex.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/${fileItem.fileIndex.id}', + ); + ref.invalidate( + cloudFileListNotifierProvider, + ); + } catch (e) { + showSnackBar('failedToDeleteFile'.tr()); + } finally { + if (context.mounted) { + hideLoadingModal(context); + } + } + }, + ), + ); + }, + folder: + (folderItem) => ListTile( + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + child: SizedBox( + height: 48, + width: 48, + child: + const Icon( + Symbols.folder, + fill: 1, + ).center(), + ), + ), + title: Text( + folderItem.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: const Text('Folder'), + onTap: () { + // Navigate to folder + final newPath = + currentPath.value == '/' + ? '/${folderItem.name}' + : '${currentPath.value}/${folderItem.name}'; + currentPath.value = newPath; + }, + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildPathNavigation( + WidgetRef ref, + ValueNotifier currentPath, + ) { + Widget pathContent; + 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 = []; + + // 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 Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Symbols.folder), + const Gap(8), + Expanded(child: pathContent), + IconButton( + icon: const Icon(Symbols.create_new_folder), + onPressed: () => onShowCreateDirectory(ref.context, currentPath), + tooltip: 'Create Directory', + ), + IconButton( + icon: const Icon(Symbols.upload_file), + onPressed: onPickAndUpload, + tooltip: 'Upload File', + ), + ], + ), + ), + ).padding(horizontal: 8); + } + + Widget _buildEmptyDirectoryHint( + WidgetRef ref, + ValueNotifier currentPath, + ) { + return Card( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Symbols.folder_off, size: 64, color: Colors.grey), + const Gap(16), + Text( + 'This directory is empty', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(ref.context).textTheme.bodyLarge?.color, + ), + ), + const Gap(8), + Text( + 'Upload files or create subdirectories to populate this path.\n' + 'Directories are created implicitly when you upload files to them.', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of( + ref.context, + ).textTheme.bodyMedium?.color?.withOpacity(0.7), + ), + ), + const Gap(16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: onPickAndUpload, + icon: const Icon(Symbols.upload_file), + label: const Text('Upload Files'), + ), + const Gap(12), + OutlinedButton.icon( + onPressed: + () => onShowCreateDirectory(ref.context, currentPath), + icon: const Icon(Symbols.create_new_folder), + label: const Text('Create Directory'), + ), + ], + ), + ], + ), + ), + ); + } +}