FIle index

This commit is contained in:
2025-11-12 22:09:22 +08:00
parent ac7cb29afe
commit c779c7523c
8 changed files with 513 additions and 207 deletions

View File

@@ -1,3 +1,6 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- drift: true
- provider: true
- shared_preferences: true

View File

@@ -60,3 +60,19 @@ sealed class SnCloudFile with _$SnCloudFile {
factory SnCloudFile.fromJson(Map<String, dynamic> json) => factory SnCloudFile.fromJson(Map<String, dynamic> json) =>
_$SnCloudFileFromJson(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<String, dynamic> json) =>
_$SnCloudFileIndexFromJson(json);
}

View File

@@ -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<SnCloudFileIndex> get copyWith => _$SnCloudFileIndexCopyWithImpl<SnCloudFileIndex>(this as SnCloudFileIndex, _$identity);
/// Serializes this SnCloudFileIndex to a JSON map.
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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 // dart format on

View File

@@ -78,3 +78,28 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
}; };
_SnCloudFileIndex _$SnCloudFileIndexFromJson(Map<String, dynamic> 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<String, dynamic>),
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<String, dynamic> _$SnCloudFileIndexToJson(_SnCloudFileIndex instance) =>
<String, dynamic>{
'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(),
};

View File

@@ -349,6 +349,7 @@ class EnhancedFileUploader extends FileUploader {
String? encryptPassword, String? encryptPassword,
String? expiredAt, String? expiredAt,
int? customChunkSize, int? customChunkSize,
String? path,
Function(double? progress, Duration estimate)? onProgress, Function(double? progress, Duration estimate)? onProgress,
}) async { }) async {
// Step 1: Create upload task // Step 1: Create upload task
@@ -362,6 +363,7 @@ class EnhancedFileUploader extends FileUploader {
encryptPassword: encryptPassword, encryptPassword: encryptPassword,
expiredAt: expiredAt, expiredAt: expiredAt,
chunkSize: customChunkSize, chunkSize: customChunkSize,
path: path,
); );
int totalSize; int totalSize;

View File

@@ -1,11 +1,13 @@
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/network.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/utils/format.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -22,54 +24,35 @@ part 'file_list.g.dart';
@riverpod @riverpod
class CloudFileListNotifier extends _$CloudFileListNotifier class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<SnCloudFile> { with CursorPagingNotifierMixin<SnCloudFileIndex> {
String? _poolId; String _currentPath = '/';
bool _includeRecycled = false;
void setFilters(String? poolId, bool includeRecycled) { void setPath(String path) {
_poolId = poolId; _currentPath = path;
_includeRecycled = includeRecycled;
ref.invalidateSelf(); ref.invalidateSelf();
} }
@override @override
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null); Future<CursorPagingData<SnCloudFileIndex>> build() => fetch(cursor: null);
@override @override
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async { Future<CursorPagingData<SnCloudFileIndex>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final queryParameters = <String, dynamic>{'offset': offset, 'take': take};
// Add filter parameters
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_includeRecycled) {
queryParameters['recycled'] = 'true';
}
final response = await client.get( final response = await client.get(
'/drive/files/me', '/drive/index/browse',
queryParameters: queryParameters, queryParameters: {'path': _currentPath},
); );
final List<SnCloudFile> items = final List<SnCloudFileIndex> items =
(response.data as List) (response.data['files'] as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) .map((e) => SnCloudFileIndex.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
final total = int.parse(response.headers.value('X-Total') ?? '0');
final hasMore = offset + items.length < total; // The new API returns all files in the path, no pagination
final nextCursor = hasMore ? (offset + items.length).toString() : null; return CursorPagingData(items: items, hasMore: false, nextCursor: null);
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
} }
} }
@@ -92,19 +75,18 @@ class FileListScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Filter state // Path navigation state
final selectedPool = useState<String?>(null); final currentPath = useState<String>('/');
final includeRecycled = useState(false);
final usageAsync = ref.watch(billingUsageProvider); final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider); final quotaAsync = ref.watch(billingQuotaProvider);
// Update notifier filters when state changes // Update notifier path when state changes
useEffect(() { useEffect(() {
final notifier = ref.read(cloudFileListNotifierProvider.notifier); final notifier = ref.read(cloudFileListNotifierProvider.notifier);
notifier.setFilters(selectedPool.value, includeRecycled.value); notifier.setPath(currentPath.value);
return null; return null;
}, [selectedPool.value, includeRecycled.value]); }, [currentPath.value]);
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
@@ -112,6 +94,11 @@ class FileListScreen extends HookConsumerWidget {
title: Text('Files'), title: Text('Files'),
leading: const PageBackButton(), leading: const PageBackButton(),
actions: [ actions: [
IconButton(
icon: const Icon(Symbols.upload_file),
onPressed: () => _pickAndUploadFile(ref, currentPath.value),
tooltip: 'Upload File',
),
IconButton( IconButton(
icon: const Icon(Symbols.bar_chart), icon: const Icon(Symbols.bar_chart),
onPressed: onPressed:
@@ -127,14 +114,7 @@ class FileListScreen extends HookConsumerWidget {
body: usageAsync.when( body: usageAsync.when(
data: data:
(usage) => quotaAsync.when( (usage) => quotaAsync.when(
data: data: (quota) => _buildQuotaUI(usage, quota, ref, currentPath),
(quota) => _buildQuotaUI(
usage,
quota,
ref,
selectedPool,
includeRecycled,
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')), error: (e, _) => Center(child: Text('Error loading quota')),
), ),
@@ -148,16 +128,13 @@ class FileListScreen extends HookConsumerWidget {
Map<String, dynamic>? usage, Map<String, dynamic>? usage,
Map<String, dynamic>? quota, Map<String, dynamic>? quota,
WidgetRef ref, WidgetRef ref,
ValueNotifier<String?> selectedPool, ValueNotifier<String> currentPath,
ValueNotifier<bool> includeRecycled,
) { ) {
if (usage == null) return const SizedBox.shrink(); if (usage == null) return const SizedBox.shrink();
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
const SliverGap(8), const SliverGap(8),
SliverToBoxAdapter( SliverToBoxAdapter(child: _buildPathNavigation(ref, currentPath)),
child: _buildFilters(ref, selectedPool, includeRecycled),
),
const SliverGap(8), const SliverGap(8),
PagingHelperSliverView( PagingHelperSliverView(
provider: cloudFileListNotifierProvider, provider: cloudFileListNotifierProvider,
@@ -172,7 +149,8 @@ class FileListScreen extends HookConsumerWidget {
} }
final item = data.items[index]; final item = data.items[index];
final itemType = item.mimeType?.split('/').firstOrNull; final file = item.file;
final itemType = file.mimeType?.split('/').firstOrNull;
return ListTile( return ListTile(
leading: ClipRRect( leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -180,7 +158,7 @@ class FileListScreen extends HookConsumerWidget {
height: 48, height: 48,
width: 48, width: 48,
child: switch (itemType) { child: switch (itemType) {
'image' => CloudImageWidget(file: item), 'image' => CloudImageWidget(file: file),
'audio' => 'audio' =>
const Icon(Symbols.audio_file, fill: 1).center(), const Icon(Symbols.audio_file, fill: 1).center(),
'video' => 'video' =>
@@ -191,20 +169,20 @@ class FileListScreen extends HookConsumerWidget {
), ),
), ),
title: title:
item.name.isEmpty file.name.isEmpty
? Text('untitled').tr().italic() ? Text('untitled').tr().italic()
: Text( : Text(
item.name, file.name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text(formatFileSize(item.size)), subtitle: Text(formatFileSize(file.size)),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item), builder: (context) => FileInfoSheet(item: file),
); );
}, },
trailing: IconButton( trailing: IconButton(
@@ -219,7 +197,7 @@ class FileListScreen extends HookConsumerWidget {
if (context.mounted) showLoadingModal(context); if (context.mounted) showLoadingModal(context);
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.delete('/drive/files/${item.id}'); await client.delete('/drive/index/remove/${item.id}');
ref.invalidate(cloudFileListNotifierProvider); ref.invalidate(cloudFileListNotifierProvider);
} catch (e) { } catch (e) {
showSnackBar('failedToDeleteFile'.tr()); showSnackBar('failedToDeleteFile'.tr());
@@ -236,138 +214,21 @@ class FileListScreen extends HookConsumerWidget {
); );
} }
Widget _buildFilters( Widget _buildPathNavigation(
WidgetRef ref, WidgetRef ref,
ValueNotifier<String?> selectedPool, ValueNotifier<String> currentPath,
ValueNotifier<bool> includeRecycled,
) { ) {
final poolsAsync = ref.watch(poolsProvider); if (currentPath.value == '/') {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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<String?>(
value: selectedPool.value,
decoration: InputDecoration(
labelText: 'Pool',
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text('allPools'.tr()),
),
...pools.map(
(pool) => DropdownMenuItem<String?>(
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( child: Row(
children: [ children: [
Text('includeRecycled'.tr()), const Icon(Symbols.folder),
const Gap(8), const Gap(8),
Switch( Text(
value: includeRecycled.value, 'Root Directory',
onChanged: style: TextStyle(fontWeight: FontWeight.bold),
(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<String?>(
value: selectedPool.value,
decoration: const InputDecoration(
labelText: 'Pool',
border: OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text('allPools'.tr()),
),
...pools.map(
(pool) => DropdownMenuItem<String?>(
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,
),
],
),
],
);
},
), ),
], ],
), ),
@@ -375,23 +236,112 @@ class FileListScreen extends HookConsumerWidget {
).padding(horizontal: 8); ).padding(horizontal: 8);
} }
Future<void> _deleteRecycledFiles(WidgetRef ref) async { final pathParts =
final confirmed = await showConfirmAlert( currentPath.value.split('/').where((part) => part.isNotEmpty).toList();
'confirmDeleteRecycledFiles'.tr(), final breadcrumbs = <Widget>[];
'deleteRecycledFiles'.tr(),
);
if (!confirmed) return;
if (ref.context.mounted) showLoadingModal(ref.context); // 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 { try {
final client = ref.read(apiClientProvider); final result = await FilePicker.platform.pickFiles(
await client.delete('/drive/files/recycled'); 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); ref.invalidate(cloudFileListNotifierProvider);
showSnackBar('recycledFilesDeleted'.tr()); showSnackBar('File uploaded successfully');
}
})
.catchError((error) {
showSnackBar('Failed to upload file: $error');
});
}
}
}
} catch (e) { } catch (e) {
showSnackBar('failedToDeleteRecycledFiles'.tr()); showSnackBar('Error picking file: $e');
} finally {
if (ref.context.mounted) hideLoadingModal(ref.context);
} }
} }

View File

@@ -45,13 +45,13 @@ final billingQuotaProvider =
// ignore: unused_element // ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>; typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() => String _$cloudFileListNotifierHash() =>
r'22c45a8ea23147a3835ba870ad2f0bb833f853ea'; r'a29adc14e3eede41be05de373785f13439cf9e60';
/// See also [CloudFileListNotifier]. /// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier) @ProviderFor(CloudFileListNotifier)
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
CloudFileListNotifier, CloudFileListNotifier,
CursorPagingData<SnCloudFile> CursorPagingData<SnCloudFileIndex>
>.internal( >.internal(
CloudFileListNotifier.new, CloudFileListNotifier.new,
name: r'cloudFileListNotifierProvider', name: r'cloudFileListNotifierProvider',
@@ -64,6 +64,6 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
); );
typedef _$CloudFileListNotifier = typedef _$CloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>; AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFileIndex>>;
// ignore_for_file: type=lint // 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 // 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

View File

@@ -75,6 +75,7 @@ class FileUploader {
String? encryptPassword, String? encryptPassword,
String? expiredAt, String? expiredAt,
int? chunkSize, int? chunkSize,
String? path,
}) async { }) async {
String hash; String hash;
int fileSize; int fileSize;
@@ -100,6 +101,7 @@ class FileUploader {
'encrypt_password': encryptPassword, 'encrypt_password': encryptPassword,
'expired_at': expiredAt, 'expired_at': expiredAt,
'chunk_size': chunkSize, 'chunk_size': chunkSize,
'path': path,
}, },
); );
@@ -150,6 +152,7 @@ class FileUploader {
String? encryptPassword, String? encryptPassword,
String? expiredAt, String? expiredAt,
int? customChunkSize, int? customChunkSize,
String? path,
Function(double? progress, Duration estimate)? onProgress, Function(double? progress, Duration estimate)? onProgress,
}) async { }) async {
// Step 1: Create upload task // Step 1: Create upload task
@@ -163,6 +166,7 @@ class FileUploader {
encryptPassword: encryptPassword, encryptPassword: encryptPassword,
expiredAt: expiredAt, expiredAt: expiredAt,
chunkSize: customChunkSize, chunkSize: customChunkSize,
path: path,
); );
if (createResponse['file_exists'] == true) { if (createResponse['file_exists'] == true) {
@@ -238,6 +242,7 @@ class FileUploader {
required UniversalFile fileData, required UniversalFile fileData,
required WidgetRef ref, required WidgetRef ref,
String? poolId, String? poolId,
String? path,
FileUploadMode? mode, FileUploadMode? mode,
Function(double? progress, Duration estimate)? onProgress, Function(double? progress, Duration estimate)? onProgress,
}) { }) {
@@ -273,8 +278,14 @@ class FileUploader {
await exif.writeAttributes(gpsAttributes); await exif.writeAttributes(gpsAttributes);
}) })
.then( .then(
(_) => (_) => _processUpload(
_processUpload(fileData, ref, poolId, onProgress, completer), fileData,
ref,
poolId,
path,
onProgress,
completer,
),
) )
.catchError((e) { .catchError((e) {
debugPrint('Error removing GPS EXIF data: $e'); debugPrint('Error removing GPS EXIF data: $e');
@@ -282,6 +293,7 @@ class FileUploader {
fileData, fileData,
ref, ref,
poolId, poolId,
path,
onProgress, onProgress,
completer, completer,
); );
@@ -291,7 +303,7 @@ class FileUploader {
} }
} }
_processUpload(fileData, ref, poolId, onProgress, completer); _processUpload(fileData, ref, poolId, path, onProgress, completer);
return completer; return completer;
} }
@@ -300,6 +312,7 @@ class FileUploader {
UniversalFile fileData, UniversalFile fileData,
WidgetRef ref, WidgetRef ref,
String? poolId, String? poolId,
String? path,
Function(double? progress, Duration estimate)? onProgress, Function(double? progress, Duration estimate)? onProgress,
Completer<SnCloudFile?> completer, Completer<SnCloudFile?> completer,
) { ) {
@@ -314,6 +327,7 @@ class FileUploader {
_performUpload( _performUpload(
fileData: data, fileData: data,
fileName: fileData.displayName ?? data.name, fileName: fileData.displayName ?? data.name,
path: path,
contentType: actualMimetype, contentType: actualMimetype,
ref: ref, ref: ref,
poolId: poolId, poolId: poolId,
@@ -342,6 +356,7 @@ class FileUploader {
fileData: bytes, fileData: bytes,
fileName: actualFilename, fileName: actualFilename,
contentType: actualMimetype, contentType: actualMimetype,
path: path,
ref: ref, ref: ref,
poolId: poolId, poolId: poolId,
onProgress: onProgress, onProgress: onProgress,
@@ -359,6 +374,7 @@ class FileUploader {
required String contentType, required String contentType,
required WidgetRef ref, required WidgetRef ref,
String? poolId, String? poolId,
String? path,
Function(double? progress, Duration estimate)? onProgress, Function(double? progress, Duration estimate)? onProgress,
required Completer<SnCloudFile?> completer, required Completer<SnCloudFile?> completer,
}) { }) {
@@ -373,6 +389,7 @@ class FileUploader {
fileName: fileName, fileName: fileName,
contentType: contentType, contentType: contentType,
poolId: poolId, poolId: poolId,
path: path,
onProgress: onProgress, onProgress: onProgress,
) )
.then((result) { .then((result) {