Compare commits
8 Commits
3.3.0+146
...
0303ef4a93
| Author | SHA1 | Date | |
|---|---|---|---|
|
0303ef4a93
|
|||
|
c2b18ce10b
|
|||
|
0767bb53ce
|
|||
|
b233f9a410
|
|||
|
256024fb46
|
|||
|
4a80aaf24d
|
|||
|
aafd160c44
|
|||
|
4a800725e3
|
@@ -11,12 +11,18 @@ part 'file_list.g.dart';
|
||||
class CloudFileListNotifier extends _$CloudFileListNotifier
|
||||
with CursorPagingNotifierMixin<FileListItem> {
|
||||
String _currentPath = '/';
|
||||
String? _poolId;
|
||||
|
||||
void setPath(String path) {
|
||||
_currentPath = path;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setPool(String? poolId) {
|
||||
_poolId = poolId;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
|
||||
|
||||
@@ -26,9 +32,15 @@ class CloudFileListNotifier extends _$CloudFileListNotifier
|
||||
}) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final queryParameters = <String, String>{'path': _currentPath};
|
||||
|
||||
if (_poolId != null) {
|
||||
queryParameters['pool'] = _poolId!;
|
||||
}
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/index/browse',
|
||||
queryParameters: {'path': _currentPath},
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final List<String> folders =
|
||||
@@ -58,6 +70,19 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
|
||||
@riverpod
|
||||
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
|
||||
with CursorPagingNotifierMixin<FileListItem> {
|
||||
String? _poolId;
|
||||
bool _recycled = false;
|
||||
|
||||
void setPool(String? poolId) {
|
||||
_poolId = poolId;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setRecycled(bool recycled) {
|
||||
_recycled = recycled;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
|
||||
|
||||
@@ -70,9 +95,22 @@ class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
|
||||
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
|
||||
const take = 50; // Default page size
|
||||
|
||||
final queryParameters = <String, String>{
|
||||
'take': take.toString(),
|
||||
'offset': offset.toString(),
|
||||
};
|
||||
|
||||
if (_poolId != null) {
|
||||
queryParameters['pool'] = _poolId!;
|
||||
}
|
||||
|
||||
if (_recycled) {
|
||||
queryParameters['recycled'] = _recycled.toString();
|
||||
}
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/index/unindexed',
|
||||
queryParameters: {'take': take.toString(), 'offset': offset.toString()},
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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/models/file_pool.dart';
|
||||
import 'package:island/pods/file_list.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
@@ -23,6 +25,7 @@ class FileListScreen extends HookConsumerWidget {
|
||||
// Path navigation state
|
||||
final currentPath = useState<String>('/');
|
||||
final mode = useState<FileListMode>(FileListMode.normal);
|
||||
final selectedPool = useState<SnFilePool?>(null);
|
||||
|
||||
final usageAsync = ref.watch(billingUsageProvider);
|
||||
final quotaAsync = ref.watch(billingQuotaProvider);
|
||||
@@ -32,7 +35,7 @@ class FileListScreen extends HookConsumerWidget {
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text('Files'),
|
||||
title: Text('files').tr(),
|
||||
leading: const PageBackButton(),
|
||||
actions: [
|
||||
IconButton(
|
||||
@@ -55,8 +58,13 @@ class FileListScreen extends HookConsumerWidget {
|
||||
usage: usage,
|
||||
quota: quota,
|
||||
currentPath: currentPath,
|
||||
selectedPool: selectedPool,
|
||||
onPickAndUpload:
|
||||
() => _pickAndUploadFile(ref, currentPath.value),
|
||||
() => _pickAndUploadFile(
|
||||
ref,
|
||||
currentPath.value,
|
||||
selectedPool.value?.id,
|
||||
),
|
||||
onShowCreateDirectory: _showCreateDirectoryDialog,
|
||||
mode: mode,
|
||||
viewMode: viewMode,
|
||||
@@ -70,7 +78,11 @@ class FileListScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async {
|
||||
Future<void> _pickAndUploadFile(
|
||||
WidgetRef ref,
|
||||
String currentPath,
|
||||
String? poolId,
|
||||
) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
@@ -92,6 +104,7 @@ class FileListScreen extends HookConsumerWidget {
|
||||
fileData: universalFile,
|
||||
ref: ref,
|
||||
path: currentPath,
|
||||
poolId: poolId,
|
||||
onProgress: (progress, _) {
|
||||
// Progress is handled by the upload tasks system
|
||||
if (progress != null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -89,19 +90,39 @@ class ImageFileContent extends HookConsumerWidget {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PhotoView(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
child: Listener(
|
||||
onPointerSignal: (pointerSignal) {
|
||||
try {
|
||||
// Handle mouse wheel zoom - cast to dynamic to access scrollDelta
|
||||
final delta =
|
||||
(pointerSignal as dynamic).scrollDelta.dy as double?;
|
||||
if (delta != null && delta != 0) {
|
||||
final currentScale = photoViewController.scale ?? 1.0;
|
||||
// Adjust scale based on scroll direction (invert for natural zoom)
|
||||
final newScale =
|
||||
delta > 0 ? currentScale * 0.9 : currentScale * 1.1;
|
||||
// Clamp scale to reasonable bounds
|
||||
final clampedScale = newScale.clamp(0.1, 10.0);
|
||||
photoViewController.scale = clampedScale;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore non-scroll events
|
||||
}
|
||||
},
|
||||
child: PhotoView(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
),
|
||||
controller: photoViewController,
|
||||
imageProvider: CloudImageWidget.provider(
|
||||
fileId: item.id,
|
||||
serverUrl: ref.watch(serverUrlProvider),
|
||||
original: showOriginal.value,
|
||||
),
|
||||
customSize: MediaQuery.of(context).size,
|
||||
basePosition: Alignment.center,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
controller: photoViewController,
|
||||
imageProvider: CloudImageWidget.provider(
|
||||
fileId: item.id,
|
||||
serverUrl: ref.watch(serverUrlProvider),
|
||||
original: showOriginal.value,
|
||||
),
|
||||
customSize: MediaQuery.of(context).size,
|
||||
basePosition: Alignment.center,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
),
|
||||
// Controls overlay
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -8,7 +9,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file_list_item.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/file_pool.dart';
|
||||
import 'package:island/pods/file_list.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
@@ -29,6 +32,7 @@ class FileListView extends HookConsumerWidget {
|
||||
final Map<String, dynamic>? usage;
|
||||
final Map<String, dynamic>? quota;
|
||||
final ValueNotifier<String> currentPath;
|
||||
final ValueNotifier<SnFilePool?> selectedPool;
|
||||
final VoidCallback onPickAndUpload;
|
||||
final Function(BuildContext, ValueNotifier<String>) onShowCreateDirectory;
|
||||
final ValueNotifier<FileListMode> mode;
|
||||
@@ -38,6 +42,7 @@ class FileListView extends HookConsumerWidget {
|
||||
required this.usage,
|
||||
required this.quota,
|
||||
required this.currentPath,
|
||||
required this.selectedPool,
|
||||
required this.onPickAndUpload,
|
||||
required this.onShowCreateDirectory,
|
||||
required this.mode,
|
||||
@@ -59,6 +64,14 @@ class FileListView extends HookConsumerWidget {
|
||||
|
||||
if (usage == null) return const SizedBox.shrink();
|
||||
|
||||
final isRefreshing = ref.watch(
|
||||
mode.value == FileListMode.normal
|
||||
? cloudFileListNotifierProvider.select((value) => value.isLoading)
|
||||
: unindexedFileListNotifierProvider.select(
|
||||
(value) => value.isLoading,
|
||||
),
|
||||
);
|
||||
|
||||
final bodyWidget = switch (mode.value) {
|
||||
FileListMode.unindexed => PagingHelperSliverView(
|
||||
provider: unindexedFileListNotifierProvider,
|
||||
@@ -101,6 +114,152 @@ class FileListView extends HookConsumerWidget {
|
||||
),
|
||||
};
|
||||
|
||||
final unindexedNotifier = ref.read(
|
||||
unindexedFileListNotifierProvider.notifier,
|
||||
);
|
||||
final cloudNotifier = ref.read(cloudFileListNotifierProvider.notifier);
|
||||
final recycled = useState<bool>(false);
|
||||
final poolsAsync = ref.watch(poolsProvider);
|
||||
|
||||
useEffect(() {
|
||||
// Sync pool when mode or selectedPool changes
|
||||
if (mode.value == FileListMode.unindexed) {
|
||||
unindexedNotifier.setPool(selectedPool.value?.id);
|
||||
} else {
|
||||
cloudNotifier.setPool(selectedPool.value?.id);
|
||||
}
|
||||
return null;
|
||||
}, [selectedPool.value, mode.value]);
|
||||
|
||||
final poolDropdownItems = poolsAsync.when(
|
||||
data:
|
||||
(pools) => [
|
||||
const DropdownMenuItem<SnFilePool>(
|
||||
value: null,
|
||||
child: Text('All Pools', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
...pools.map(
|
||||
(p) => DropdownMenuItem<SnFilePool>(
|
||||
value: p,
|
||||
child: Text(p.name, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
loading: () => const <DropdownMenuItem<SnFilePool>>[],
|
||||
error: (err, stack) => const <DropdownMenuItem<SnFilePool>>[],
|
||||
);
|
||||
|
||||
final poolDropdown = DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SnFilePool>(
|
||||
value: selectedPool.value,
|
||||
items: poolDropdownItems,
|
||||
onChanged:
|
||||
isRefreshing
|
||||
? null
|
||||
: (value) {
|
||||
selectedPool.value = value;
|
||||
if (mode.value == FileListMode.unindexed) {
|
||||
unindexedNotifier.setPool(value?.id);
|
||||
} else {
|
||||
cloudNotifier.setPool(value?.id);
|
||||
}
|
||||
},
|
||||
customButton: Container(
|
||||
height: 28,
|
||||
width: 200,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(ref.context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(Symbols.pool, size: 16),
|
||||
Flexible(
|
||||
child: Text(
|
||||
selectedPool.value?.name ?? 'All files',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).fontSize(12),
|
||||
),
|
||||
],
|
||||
).height(24),
|
||||
),
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.zero,
|
||||
height: 28,
|
||||
width: 200,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
dropdownStyleData: const DropdownStyleData(maxHeight: 200),
|
||||
),
|
||||
);
|
||||
|
||||
late Widget pathContent;
|
||||
if (mode.value == FileListMode.unindexed) {
|
||||
pathContent = const Text(
|
||||
'Unindexed Files',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
);
|
||||
} else if (currentPath.value == '/') {
|
||||
pathContent = const Text(
|
||||
'Root Directory',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
);
|
||||
} else {
|
||||
final pathParts =
|
||||
currentPath.value
|
||||
.split('/')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
final breadcrumbs = <Widget>[];
|
||||
|
||||
// Add root
|
||||
breadcrumbs.add(
|
||||
InkWell(
|
||||
onTap: () => currentPath.value = '/',
|
||||
child: const 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: const 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 DropTarget(
|
||||
onDragDone: (details) async {
|
||||
dragging.value = false;
|
||||
@@ -115,7 +274,8 @@ class FileListView extends HookConsumerWidget {
|
||||
final completer = FileUploader.createCloudFile(
|
||||
fileData: universalFile,
|
||||
ref: ref,
|
||||
path: currentPath.value,
|
||||
path: mode.value == FileListMode.normal ? currentPath.value : null,
|
||||
poolId: selectedPool.value?.id,
|
||||
onProgress: (progress, _) {
|
||||
// Progress is handled by the upload tasks system
|
||||
if (progress != null) {
|
||||
@@ -147,9 +307,130 @@ class FileListView extends HookConsumerWidget {
|
||||
? Theme.of(context).primaryColor.withOpacity(0.1)
|
||||
: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Gap(8),
|
||||
_buildPathNavigation(ref, currentPath),
|
||||
const Gap(12),
|
||||
poolDropdown.padding(horizontal: 16),
|
||||
const Gap(6),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
mode.value == FileListMode.unindexed
|
||||
? Symbols.inventory_2
|
||||
: currentPath.value != '/'
|
||||
? Symbols.arrow_back
|
||||
: Symbols.folder,
|
||||
),
|
||||
onPressed:
|
||||
isRefreshing
|
||||
? null
|
||||
: () {
|
||||
if (mode.value == FileListMode.unindexed) {
|
||||
mode.value = FileListMode.normal;
|
||||
currentPath.value = '/';
|
||||
} else {
|
||||
final pathParts =
|
||||
currentPath.value
|
||||
.split('/')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
if (pathParts.isNotEmpty) {
|
||||
pathParts.removeLast();
|
||||
currentPath.value =
|
||||
pathParts.isEmpty
|
||||
? '/'
|
||||
: '/${pathParts.join('/')}';
|
||||
}
|
||||
}
|
||||
},
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: AbsorbPointer(
|
||||
absorbing: isRefreshing,
|
||||
child: pathContent,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
viewMode.value == FileListViewMode.list
|
||||
? Symbols.view_module
|
||||
: Symbols.list,
|
||||
),
|
||||
onPressed:
|
||||
() =>
|
||||
viewMode.value =
|
||||
viewMode.value == FileListViewMode.list
|
||||
? FileListViewMode.waterfall
|
||||
: FileListViewMode.list,
|
||||
tooltip:
|
||||
viewMode.value == FileListViewMode.list
|
||||
? 'Switch to Waterfall View'
|
||||
: 'Switch to List View',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
if (mode.value == FileListMode.normal)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.create_new_folder),
|
||||
onPressed:
|
||||
() =>
|
||||
onShowCreateDirectory(ref.context, currentPath),
|
||||
tooltip: 'Create Directory',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
if (mode.value == FileListMode.unindexed)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
recycled.value
|
||||
? Symbols.delete_forever
|
||||
: Symbols.restore_from_trash,
|
||||
),
|
||||
onPressed: () {
|
||||
recycled.value = !recycled.value;
|
||||
unindexedNotifier.setRecycled(recycled.value);
|
||||
},
|
||||
tooltip:
|
||||
recycled.value
|
||||
? 'Show Active Files'
|
||||
: 'Show Recycle Bin',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.upload_file),
|
||||
onPressed: onPickAndUpload,
|
||||
tooltip: 'Upload File',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
if (mode.value == FileListMode.unindexed && recycled.value)
|
||||
_buildClearRecycledButton(ref).padding(horizontal: 8),
|
||||
if (isRefreshing)
|
||||
const LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
).padding(horizontal: 16, top: 6, bottom: 4),
|
||||
const Gap(8),
|
||||
if (mode.value == FileListMode.normal && currentPath.value == '/')
|
||||
_buildUnindexedFilesEntry(ref).padding(bottom: 12),
|
||||
@@ -302,155 +583,6 @@ class FileListView extends HookConsumerWidget {
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildPathNavigation(
|
||||
WidgetRef ref,
|
||||
ValueNotifier<String> currentPath,
|
||||
) {
|
||||
Widget pathContent;
|
||||
if (mode.value == FileListMode.unindexed) {
|
||||
pathContent = Row(
|
||||
children: [
|
||||
Text(
|
||||
'Unindexed Files',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else 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 = <Widget>[];
|
||||
|
||||
// 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 SizedBox(
|
||||
height: 64,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
mode.value == FileListMode.unindexed
|
||||
? Symbols.inventory_2
|
||||
: currentPath.value != '/'
|
||||
? Symbols.arrow_back
|
||||
: Symbols.folder,
|
||||
),
|
||||
onPressed: () {
|
||||
if (mode.value == FileListMode.unindexed) {
|
||||
mode.value = FileListMode.normal;
|
||||
currentPath.value = '/';
|
||||
} else {
|
||||
final pathParts =
|
||||
currentPath.value
|
||||
.split('/')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
if (pathParts.isNotEmpty) {
|
||||
pathParts.removeLast();
|
||||
currentPath.value =
|
||||
pathParts.isEmpty ? '/' : '/${pathParts.join('/')}';
|
||||
}
|
||||
}
|
||||
},
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(child: pathContent),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
viewMode.value == FileListViewMode.list
|
||||
? Symbols.view_module
|
||||
: Symbols.list,
|
||||
),
|
||||
onPressed:
|
||||
() =>
|
||||
viewMode.value =
|
||||
viewMode.value == FileListViewMode.list
|
||||
? FileListViewMode.waterfall
|
||||
: FileListViewMode.list,
|
||||
tooltip:
|
||||
viewMode.value == FileListViewMode.list
|
||||
? 'Switch to Waterfall View'
|
||||
: 'Switch to List View',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
if (mode.value == FileListMode.normal) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.create_new_folder),
|
||||
onPressed:
|
||||
() => onShowCreateDirectory(ref.context, currentPath),
|
||||
tooltip: 'Create Directory',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.upload_file),
|
||||
onPressed: onPickAndUpload,
|
||||
tooltip: 'Upload File',
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnindexedFilesEntry(WidgetRef ref) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -485,7 +617,10 @@ class FileListView extends HookConsumerWidget {
|
||||
ValueNotifier<String> currentPath,
|
||||
) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 16),
|
||||
margin:
|
||||
viewMode.value == FileListViewMode.waterfall
|
||||
? const EdgeInsets.fromLTRB(0, 0, 0, 16)
|
||||
: const EdgeInsets.fromLTRB(12, 0, 12, 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||
child: Column(
|
||||
@@ -922,7 +1057,10 @@ class FileListView extends HookConsumerWidget {
|
||||
|
||||
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
margin:
|
||||
viewMode.value == FileListViewMode.waterfall
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.fromLTRB(12, 0, 12, 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||
child: Column(
|
||||
@@ -954,4 +1092,59 @@ class FileListView extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClearRecycledButton(WidgetRef ref) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
const Icon(Symbols.recycling).padding(horizontal: 2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Clear All Recycled Files').bold(),
|
||||
const Text(
|
||||
'Permanently delete all marked recycled files to free up space.',
|
||||
).fontSize(13),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Symbols.delete_forever),
|
||||
label: const Text('Clear'),
|
||||
onPressed: () async {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'Are you sure you want to clear all recycled files?',
|
||||
'Clear Recycled Files',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (ref.context.mounted) {
|
||||
showLoadingModal(ref.context);
|
||||
}
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.delete(
|
||||
'/drive/files/me/recycle',
|
||||
);
|
||||
final count = response.data['count'] as int? ?? 0;
|
||||
showSnackBar('Cleared $count recycled files.');
|
||||
ref.invalidate(unindexedFileListNotifierProvider);
|
||||
} catch (e) {
|
||||
showSnackBar('Failed to clear recycled files.');
|
||||
} finally {
|
||||
if (ref.context.mounted) {
|
||||
hideLoadingModal(ref.context);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@@ -29,7 +29,12 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Newest first
|
||||
|
||||
final isVisible = activeTasks.isNotEmpty;
|
||||
final isVisibleOverride = useState<bool?>(null);
|
||||
final pendingHide = useState(false);
|
||||
final hideTimer = useState<Timer?>(null);
|
||||
final isVisible =
|
||||
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
|
||||
!pendingHide.value;
|
||||
final slideController = useAnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
@@ -48,6 +53,24 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [isVisible]);
|
||||
|
||||
// Handle hide delay when tasks complete
|
||||
useEffect(() {
|
||||
if (activeTasks.isEmpty && (isVisibleOverride.value ?? false) == false) {
|
||||
// No active tasks and not manually visible (not expanded)
|
||||
hideTimer.value = Timer(const Duration(seconds: 2), () {
|
||||
pendingHide.value = true;
|
||||
});
|
||||
} else {
|
||||
// Cancel any pending hide and reset
|
||||
hideTimer.value?.cancel();
|
||||
hideTimer.value = null;
|
||||
pendingHide.value = false;
|
||||
}
|
||||
return () {
|
||||
hideTimer.value?.cancel();
|
||||
};
|
||||
}, [activeTasks.length, isVisibleOverride.value]);
|
||||
|
||||
if (!isVisible && slideController.status == AnimationStatus.dismissed) {
|
||||
// If not visible and animation is complete (back to start), don't show anything
|
||||
return const SizedBox.shrink();
|
||||
@@ -63,6 +86,7 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
position: slideAnimation,
|
||||
child: _UploadOverlayContent(
|
||||
activeTasks: activeTasks,
|
||||
onVisibilityChanged: (bool? v) => isVisibleOverride.value = v,
|
||||
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
|
||||
),
|
||||
);
|
||||
@@ -71,12 +95,15 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
|
||||
class _UploadOverlayContent extends HookConsumerWidget {
|
||||
final List<DriveTask> activeTasks;
|
||||
final void Function(bool?) onVisibilityChanged;
|
||||
|
||||
const _UploadOverlayContent({required this.activeTasks});
|
||||
const _UploadOverlayContent({
|
||||
required this.activeTasks,
|
||||
required this.onVisibilityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isExpanded = useState(false);
|
||||
final animationController = useAnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
initialValue: 0.0,
|
||||
@@ -90,12 +117,15 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
final isExpanded = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
if (isExpanded.value) {
|
||||
animationController.forward();
|
||||
} else {
|
||||
animationController.reverse();
|
||||
}
|
||||
onVisibilityChanged.call(isExpanded.value);
|
||||
return null;
|
||||
}, [isExpanded.value]);
|
||||
|
||||
@@ -256,6 +286,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
ref
|
||||
.read(uploadTasksProvider.notifier)
|
||||
.clearCompletedTasks();
|
||||
isExpanded.value = false;
|
||||
},
|
||||
tileColor:
|
||||
Theme.of(
|
||||
|
||||
Reference in New Issue
Block a user