diff --git a/devtools_options.yaml b/devtools_options.yaml index fa0b357c..6b636a0b 100644 --- a/devtools_options.yaml +++ b/devtools_options.yaml @@ -1,3 +1,6 @@ description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: + - drift: true + - provider: true + - shared_preferences: true \ No newline at end of file diff --git a/lib/models/file.dart b/lib/models/file.dart index 05caed89..007f20a0 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -60,3 +60,19 @@ sealed class SnCloudFile with _$SnCloudFile { factory SnCloudFile.fromJson(Map json) => _$SnCloudFileFromJson(json); } + +@freezed +sealed class SnCloudFileIndex with _$SnCloudFileIndex { + const factory SnCloudFileIndex({ + required String id, + required String path, + required String fileId, + required SnCloudFile file, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnCloudFileIndex; + + factory SnCloudFileIndex.fromJson(Map json) => + _$SnCloudFileIndexFromJson(json); +} diff --git a/lib/models/file.freezed.dart b/lib/models/file.freezed.dart index 8cb8545d..f34e17a1 100644 --- a/lib/models/file.freezed.dart +++ b/lib/models/file.freezed.dart @@ -622,4 +622,297 @@ $SnFilePoolCopyWith<$Res>? get pool { } } + +/// @nodoc +mixin _$SnCloudFileIndex { + + String get id; String get path; String get fileId; SnCloudFile get file; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnCloudFileIndex +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnCloudFileIndexCopyWith get copyWith => _$SnCloudFileIndexCopyWithImpl(this as SnCloudFileIndex, _$identity); + + /// Serializes this SnCloudFileIndex to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFileIndex&&(identical(other.id, id) || other.id == id)&&(identical(other.path, path) || other.path == path)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,path,fileId,file,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnCloudFileIndex(id: $id, path: $path, fileId: $fileId, file: $file, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnCloudFileIndexCopyWith<$Res> { + factory $SnCloudFileIndexCopyWith(SnCloudFileIndex value, $Res Function(SnCloudFileIndex) _then) = _$SnCloudFileIndexCopyWithImpl; +@useResult +$Res call({ + String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnCloudFileCopyWith<$Res> get file; + +} +/// @nodoc +class _$SnCloudFileIndexCopyWithImpl<$Res> + implements $SnCloudFileIndexCopyWith<$Res> { + _$SnCloudFileIndexCopyWithImpl(this._self, this._then); + + final SnCloudFileIndex _self; + final $Res Function(SnCloudFileIndex) _then; + +/// Create a copy of SnCloudFileIndex +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? path = null,Object? fileId = null,Object? file = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable +as String,file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable +as SnCloudFile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} +/// Create a copy of SnCloudFileIndex +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileCopyWith<$Res> get file { + + return $SnCloudFileCopyWith<$Res>(_self.file, (value) { + return _then(_self.copyWith(file: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [SnCloudFileIndex]. +extension SnCloudFileIndexPatterns on SnCloudFileIndex { +/// 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( _SnCloudFileIndex value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnCloudFileIndex() when $default != null: +return $default(_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(TResult Function( _SnCloudFileIndex value) $default,){ +final _that = this; +switch (_that) { +case _SnCloudFileIndex(): +return $default(_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( _SnCloudFileIndex value)? $default,){ +final _that = this; +switch (_that) { +case _SnCloudFileIndex() when $default != null: +return $default(_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( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnCloudFileIndex() when $default != null: +return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);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(TResult Function( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; +switch (_that) { +case _SnCloudFileIndex(): +return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);} +} +/// 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( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; +switch (_that) { +case _SnCloudFileIndex() when $default != null: +return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnCloudFileIndex implements SnCloudFileIndex { + const _SnCloudFileIndex({required this.id, required this.path, required this.fileId, required this.file, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnCloudFileIndex.fromJson(Map json) => _$SnCloudFileIndexFromJson(json); + +@override final String id; +@override final String path; +@override final String fileId; +@override final SnCloudFile file; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnCloudFileIndex +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnCloudFileIndexCopyWith<_SnCloudFileIndex> get copyWith => __$SnCloudFileIndexCopyWithImpl<_SnCloudFileIndex>(this, _$identity); + +@override +Map toJson() { + return _$SnCloudFileIndexToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFileIndex&&(identical(other.id, id) || other.id == id)&&(identical(other.path, path) || other.path == path)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,path,fileId,file,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnCloudFileIndex(id: $id, path: $path, fileId: $fileId, file: $file, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnCloudFileIndexCopyWith<$Res> implements $SnCloudFileIndexCopyWith<$Res> { + factory _$SnCloudFileIndexCopyWith(_SnCloudFileIndex value, $Res Function(_SnCloudFileIndex) _then) = __$SnCloudFileIndexCopyWithImpl; +@override @useResult +$Res call({ + String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnCloudFileCopyWith<$Res> get file; + +} +/// @nodoc +class __$SnCloudFileIndexCopyWithImpl<$Res> + implements _$SnCloudFileIndexCopyWith<$Res> { + __$SnCloudFileIndexCopyWithImpl(this._self, this._then); + + final _SnCloudFileIndex _self; + final $Res Function(_SnCloudFileIndex) _then; + +/// Create a copy of SnCloudFileIndex +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? path = null,Object? fileId = null,Object? file = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnCloudFileIndex( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable +as String,file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable +as SnCloudFile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +/// Create a copy of SnCloudFileIndex +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileCopyWith<$Res> get file { + + return $SnCloudFileCopyWith<$Res>(_self.file, (value) { + return _then(_self.copyWith(file: value)); + }); +} +} + // dart format on diff --git a/lib/models/file.g.dart b/lib/models/file.g.dart index f92e7d77..95464852 100644 --- a/lib/models/file.g.dart +++ b/lib/models/file.g.dart @@ -78,3 +78,28 @@ Map _$SnCloudFileToJson(_SnCloudFile instance) => 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(), }; + +_SnCloudFileIndex _$SnCloudFileIndexFromJson(Map json) => + _SnCloudFileIndex( + id: json['id'] as String, + path: json['path'] as String, + fileId: json['file_id'] as String, + file: SnCloudFile.fromJson(json['file'] as Map), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); + +Map _$SnCloudFileIndexToJson(_SnCloudFileIndex instance) => + { + 'id': instance.id, + 'path': instance.path, + 'file_id': instance.fileId, + 'file': instance.file.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/pods/upload_tasks.dart b/lib/pods/upload_tasks.dart index 4d41832a..57504b38 100644 --- a/lib/pods/upload_tasks.dart +++ b/lib/pods/upload_tasks.dart @@ -349,6 +349,7 @@ class EnhancedFileUploader extends FileUploader { String? encryptPassword, String? expiredAt, int? customChunkSize, + String? path, Function(double? progress, Duration estimate)? onProgress, }) async { // Step 1: Create upload task @@ -362,6 +363,7 @@ class EnhancedFileUploader extends FileUploader { encryptPassword: encryptPassword, expiredAt: expiredAt, chunkSize: customChunkSize, + path: path, ); int totalSize; diff --git a/lib/screens/files/file_list.dart b/lib/screens/files/file_list.dart index 0a1ef367..50226a60 100644 --- a/lib/screens/files/file_list.dart +++ b/lib/screens/files/file_list.dart @@ -1,11 +1,13 @@ +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_pool.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'; @@ -22,54 +24,35 @@ part 'file_list.g.dart'; @riverpod class CloudFileListNotifier extends _$CloudFileListNotifier - with CursorPagingNotifierMixin { - String? _poolId; - bool _includeRecycled = false; + with CursorPagingNotifierMixin { + String _currentPath = '/'; - void setFilters(String? poolId, bool includeRecycled) { - _poolId = poolId; - _includeRecycled = includeRecycled; + void setPath(String path) { + _currentPath = path; ref.invalidateSelf(); } @override - Future> build() => fetch(cursor: null); + Future> build() => fetch(cursor: null); @override - Future> fetch({required String? cursor}) async { + Future> fetch({ + required String? cursor, + }) async { final client = ref.read(apiClientProvider); - final offset = cursor == null ? 0 : int.parse(cursor); - final take = 20; - - final queryParameters = {'offset': offset, 'take': take}; - - // Add filter parameters - if (_poolId != null) { - queryParameters['pool'] = _poolId!; - } - if (_includeRecycled) { - queryParameters['recycled'] = 'true'; - } final response = await client.get( - '/drive/files/me', - queryParameters: queryParameters, + '/drive/index/browse', + queryParameters: {'path': _currentPath}, ); - final List items = - (response.data as List) - .map((e) => SnCloudFile.fromJson(e as Map)) + final List items = + (response.data['files'] as List) + .map((e) => SnCloudFileIndex.fromJson(e as Map)) .toList(); - final total = int.parse(response.headers.value('X-Total') ?? '0'); - final hasMore = offset + items.length < total; - final nextCursor = hasMore ? (offset + items.length).toString() : null; - - return CursorPagingData( - items: items, - hasMore: hasMore, - nextCursor: nextCursor, - ); + // The new API returns all files in the path, no pagination + return CursorPagingData(items: items, hasMore: false, nextCursor: null); } } @@ -92,19 +75,18 @@ class FileListScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Filter state - final selectedPool = useState(null); - final includeRecycled = useState(false); + // Path navigation state + final currentPath = useState('/'); final usageAsync = ref.watch(billingUsageProvider); final quotaAsync = ref.watch(billingQuotaProvider); - // Update notifier filters when state changes + // Update notifier path when state changes useEffect(() { final notifier = ref.read(cloudFileListNotifierProvider.notifier); - notifier.setFilters(selectedPool.value, includeRecycled.value); + notifier.setPath(currentPath.value); return null; - }, [selectedPool.value, includeRecycled.value]); + }, [currentPath.value]); return AppScaffold( isNoBackground: false, @@ -112,6 +94,11 @@ class FileListScreen extends HookConsumerWidget { 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: @@ -127,14 +114,7 @@ class FileListScreen extends HookConsumerWidget { body: usageAsync.when( data: (usage) => quotaAsync.when( - data: - (quota) => _buildQuotaUI( - usage, - quota, - ref, - selectedPool, - includeRecycled, - ), + data: (quota) => _buildQuotaUI(usage, quota, ref, currentPath), loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error loading quota')), ), @@ -148,16 +128,13 @@ class FileListScreen extends HookConsumerWidget { Map? usage, Map? quota, WidgetRef ref, - ValueNotifier selectedPool, - ValueNotifier includeRecycled, + ValueNotifier currentPath, ) { if (usage == null) return const SizedBox.shrink(); return CustomScrollView( slivers: [ const SliverGap(8), - SliverToBoxAdapter( - child: _buildFilters(ref, selectedPool, includeRecycled), - ), + SliverToBoxAdapter(child: _buildPathNavigation(ref, currentPath)), const SliverGap(8), PagingHelperSliverView( provider: cloudFileListNotifierProvider, @@ -172,7 +149,8 @@ class FileListScreen extends HookConsumerWidget { } final item = data.items[index]; - final itemType = item.mimeType?.split('/').firstOrNull; + final file = item.file; + final itemType = file.mimeType?.split('/').firstOrNull; return ListTile( leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), @@ -180,7 +158,7 @@ class FileListScreen extends HookConsumerWidget { height: 48, width: 48, child: switch (itemType) { - 'image' => CloudImageWidget(file: item), + 'image' => CloudImageWidget(file: file), 'audio' => const Icon(Symbols.audio_file, fill: 1).center(), 'video' => @@ -191,20 +169,20 @@ class FileListScreen extends HookConsumerWidget { ), ), title: - item.name.isEmpty + file.name.isEmpty ? Text('untitled').tr().italic() : Text( - item.name, + file.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), - subtitle: Text(formatFileSize(item.size)), + subtitle: Text(formatFileSize(file.size)), onTap: () { showModalBottomSheet( useRootNavigator: true, context: context, isScrollControlled: true, - builder: (context) => FileInfoSheet(item: item), + builder: (context) => FileInfoSheet(item: file), ); }, trailing: IconButton( @@ -219,7 +197,7 @@ class FileListScreen extends HookConsumerWidget { if (context.mounted) showLoadingModal(context); try { final client = ref.read(apiClientProvider); - await client.delete('/drive/files/${item.id}'); + await client.delete('/drive/index/remove/${item.id}'); ref.invalidate(cloudFileListNotifierProvider); } catch (e) { showSnackBar('failedToDeleteFile'.tr()); @@ -236,138 +214,81 @@ class FileListScreen extends HookConsumerWidget { ); } - Widget _buildFilters( + Widget _buildPathNavigation( WidgetRef ref, - ValueNotifier selectedPool, - ValueNotifier includeRecycled, + ValueNotifier currentPath, ) { - final poolsAsync = ref.watch(poolsProvider); + 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - 'filters'.tr(), - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const Gap(16), - LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth > 600; - return isWide - ? Row( - children: [ - Expanded( - flex: 2, - child: poolsAsync.when( - data: - (pools) => DropdownButtonFormField( - value: selectedPool.value, - decoration: InputDecoration( - labelText: 'Pool', - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: null, - child: Text('allPools'.tr()), - ), - ...pools.map( - (pool) => DropdownMenuItem( - value: pool.id, - child: Text(pool.name), - ), - ), - ], - onChanged: - (value) => selectedPool.value = value, - ), - loading: () => const CircularProgressIndicator(), - error: (e, _) => const Text('Error loading pools'), - ), - ), - const Gap(8), - Expanded( - child: Row( - children: [ - Text('includeRecycled'.tr()), - const Gap(8), - Switch( - value: includeRecycled.value, - onChanged: - (value) => includeRecycled.value = value, - padding: EdgeInsets.zero, - ), - ], - ), - ), - const Gap(16), - IconButton( - icon: const Icon(Symbols.delete_sweep), - tooltip: 'deleteRecycledFiles'.tr(), - onPressed: - includeRecycled.value - ? () => _deleteRecycledFiles(ref) - : null, - ), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - poolsAsync.when( - data: - (pools) => DropdownButtonFormField( - value: selectedPool.value, - decoration: const InputDecoration( - labelText: 'Pool', - border: OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: null, - child: Text('allPools'.tr()), - ), - ...pools.map( - (pool) => DropdownMenuItem( - value: pool.id, - child: Text(pool.name), - ), - ), - ], - onChanged: - (value) => selectedPool.value = value, - ), - loading: () => const CircularProgressIndicator(), - error: (e, _) => const Text('Error loading pools'), - ), - const Gap(16), - Row( - children: [ - Text('includeRecycled'.tr()), - const Gap(8), - Switch( - value: includeRecycled.value, - onChanged: - (value) => includeRecycled.value = value, - ), - const Spacer(), - IconButton( - icon: const Icon(Symbols.delete_sweep), - tooltip: 'deleteRecycledFiles'.tr(), - onPressed: - includeRecycled.value - ? () => _deleteRecycledFiles(ref) - : null, - ), - ], - ), - ], - ); - }, + const Icon(Symbols.folder), + const Gap(8), + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: breadcrumbs, + ), ), ], ), @@ -375,23 +296,52 @@ class FileListScreen extends HookConsumerWidget { ).padding(horizontal: 8); } - Future _deleteRecycledFiles(WidgetRef ref) async { - final confirmed = await showConfirmAlert( - 'confirmDeleteRecycledFiles'.tr(), - 'deleteRecycledFiles'.tr(), - ); - if (!confirmed) return; - - if (ref.context.mounted) showLoadingModal(ref.context); + Future _pickAndUploadFile(WidgetRef ref, String currentPath) async { try { - final client = ref.read(apiClientProvider); - await client.delete('/drive/files/recycled'); - ref.invalidate(cloudFileListNotifierProvider); - showSnackBar('recycledFilesDeleted'.tr()); + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + withData: false, + ); + + if (result != null && result.files.isNotEmpty) { + for (final file in result.files) { + if (file.path != null) { + // Create UniversalFile from the picked file + final universalFile = UniversalFile( + data: XFile(file.path!), + type: UniversalFileType.file, + displayName: file.name, + ); + + // Upload the file with the current path + final completer = FileUploader.createCloudFile( + fileData: universalFile, + ref: ref, + path: currentPath, + onProgress: (progress, _) { + // Progress is handled by the upload tasks system + if (progress != null) { + debugPrint('Upload progress: ${(progress * 100).toInt()}%'); + } + }, + ); + + completer.future + .then((uploadedFile) { + if (uploadedFile != null) { + // Refresh the file list after successful upload + ref.invalidate(cloudFileListNotifierProvider); + showSnackBar('File uploaded successfully'); + } + }) + .catchError((error) { + showSnackBar('Failed to upload file: $error'); + }); + } + } + } } catch (e) { - showSnackBar('failedToDeleteRecycledFiles'.tr()); - } finally { - if (ref.context.mounted) hideLoadingModal(ref.context); + showSnackBar('Error picking file: $e'); } } diff --git a/lib/screens/files/file_list.g.dart b/lib/screens/files/file_list.g.dart index 6275e7c8..29e8b67c 100644 --- a/lib/screens/files/file_list.g.dart +++ b/lib/screens/files/file_list.g.dart @@ -45,13 +45,13 @@ final billingQuotaProvider = // ignore: unused_element typedef BillingQuotaRef = AutoDisposeFutureProviderRef?>; String _$cloudFileListNotifierHash() => - r'22c45a8ea23147a3835ba870ad2f0bb833f853ea'; + r'a29adc14e3eede41be05de373785f13439cf9e60'; /// 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/services/file_uploader.dart b/lib/services/file_uploader.dart index 536aebb0..d379765c 100644 --- a/lib/services/file_uploader.dart +++ b/lib/services/file_uploader.dart @@ -75,6 +75,7 @@ class FileUploader { String? encryptPassword, String? expiredAt, int? chunkSize, + String? path, }) async { String hash; int fileSize; @@ -100,6 +101,7 @@ class FileUploader { 'encrypt_password': encryptPassword, 'expired_at': expiredAt, 'chunk_size': chunkSize, + 'path': path, }, ); @@ -150,6 +152,7 @@ class FileUploader { String? encryptPassword, String? expiredAt, int? customChunkSize, + String? path, Function(double? progress, Duration estimate)? onProgress, }) async { // Step 1: Create upload task @@ -163,6 +166,7 @@ class FileUploader { encryptPassword: encryptPassword, expiredAt: expiredAt, chunkSize: customChunkSize, + path: path, ); if (createResponse['file_exists'] == true) { @@ -238,6 +242,7 @@ class FileUploader { required UniversalFile fileData, required WidgetRef ref, String? poolId, + String? path, FileUploadMode? mode, Function(double? progress, Duration estimate)? onProgress, }) { @@ -273,8 +278,14 @@ class FileUploader { await exif.writeAttributes(gpsAttributes); }) .then( - (_) => - _processUpload(fileData, ref, poolId, onProgress, completer), + (_) => _processUpload( + fileData, + ref, + poolId, + path, + onProgress, + completer, + ), ) .catchError((e) { debugPrint('Error removing GPS EXIF data: $e'); @@ -282,6 +293,7 @@ class FileUploader { fileData, ref, poolId, + path, onProgress, completer, ); @@ -291,7 +303,7 @@ class FileUploader { } } - _processUpload(fileData, ref, poolId, onProgress, completer); + _processUpload(fileData, ref, poolId, path, onProgress, completer); return completer; } @@ -300,6 +312,7 @@ class FileUploader { UniversalFile fileData, WidgetRef ref, String? poolId, + String? path, Function(double? progress, Duration estimate)? onProgress, Completer completer, ) { @@ -314,6 +327,7 @@ class FileUploader { _performUpload( fileData: data, fileName: fileData.displayName ?? data.name, + path: path, contentType: actualMimetype, ref: ref, poolId: poolId, @@ -342,6 +356,7 @@ class FileUploader { fileData: bytes, fileName: actualFilename, contentType: actualMimetype, + path: path, ref: ref, poolId: poolId, onProgress: onProgress, @@ -359,6 +374,7 @@ class FileUploader { required String contentType, required WidgetRef ref, String? poolId, + String? path, Function(double? progress, Duration estimate)? onProgress, required Completer completer, }) { @@ -373,6 +389,7 @@ class FileUploader { fileName: fileName, contentType: contentType, poolId: poolId, + path: path, onProgress: onProgress, ) .then((result) {