Compare commits

...

4 Commits

Author SHA1 Message Date
256024fb46 💄 Adjust upload overlay auto show and hide logic 2025-11-17 22:57:53 +08:00
4a80aaf24d Unindexed files filter 2025-11-17 22:57:42 +08:00
aafd160c44 🐛 Fix waterfall styling issue 2025-11-17 22:00:51 +08:00
4a800725e3 Zoom image via mosue scroll 2025-11-17 22:00:35 +08:00
5 changed files with 324 additions and 170 deletions

View File

@@ -58,6 +58,19 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
@riverpod @riverpod
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
with CursorPagingNotifierMixin<FileListItem> { 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 @override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null); Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -70,9 +83,22 @@ class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0; final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
const take = 50; // Default page size 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( final response = await client.get(
'/drive/index/unindexed', '/drive/index/unindexed',
queryParameters: {'take': take.toString(), 'offset': offset.toString()}, queryParameters: queryParameters,
); );
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0; final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;

View File

@@ -1,4 +1,5 @@
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.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';
@@ -32,7 +33,7 @@ class FileListScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text('Files'), title: Text('files').tr(),
leading: const PageBackButton(), leading: const PageBackButton(),
actions: [ actions: [
IconButton( IconButton(

View File

@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -89,19 +90,39 @@ class ImageFileContent extends HookConsumerWidget {
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: PhotoView( child: Listener(
backgroundDecoration: BoxDecoration( onPointerSignal: (pointerSignal) {
color: Colors.black.withOpacity(0.9), 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 // Controls overlay

View File

@@ -1,4 +1,5 @@
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.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';
@@ -8,7 +9,9 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file_list_item.dart'; import 'package:island/models/file_list_item.dart';
import 'package:island/models/file.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_list.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
@@ -101,6 +104,138 @@ class FileListView extends HookConsumerWidget {
), ),
}; };
final unindexedNotifier = ref.read(
unindexedFileListNotifierProvider.notifier,
);
final selectedPool = useState<SnFilePool?>(null);
final recycled = useState<bool>(false);
final poolsAsync = ref.watch(poolsProvider);
late Widget pathContent;
if (mode.value == FileListMode.unindexed) {
final unindexedItems = 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>>[],
);
pathContent = Row(
children: [
const Text(
'Unindexed Files',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Gap(8),
DropdownButtonHideUnderline(
child: DropdownButton2<SnFilePool>(
value: selectedPool.value,
items: unindexedItems,
onChanged: (value) {
selectedPool.value = value;
unindexedNotifier.setPool(value?.id);
},
customButton: Container(
height: 28,
width: 160,
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),
),
),
],
);
} 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( return DropTarget(
onDragDone: (details) async { onDragDone: (details) async {
dragging.value = false; dragging.value = false;
@@ -115,7 +250,7 @@ class FileListView extends HookConsumerWidget {
final completer = FileUploader.createCloudFile( final completer = FileUploader.createCloudFile(
fileData: universalFile, fileData: universalFile,
ref: ref, ref: ref,
path: currentPath.value, path: mode.value == FileListMode.normal ? currentPath.value : null,
onProgress: (progress, _) { onProgress: (progress, _) {
// Progress is handled by the upload tasks system // Progress is handled by the upload tasks system
if (progress != null) { if (progress != null) {
@@ -149,7 +284,116 @@ class FileListView extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
const Gap(8), const Gap(8),
_buildPathNavigation(ref, currentPath), 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,
),
),
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),
),
const Gap(8), const Gap(8),
if (mode.value == FileListMode.normal && currentPath.value == '/') if (mode.value == FileListMode.normal && currentPath.value == '/')
_buildUnindexedFilesEntry(ref).padding(bottom: 12), _buildUnindexedFilesEntry(ref).padding(bottom: 12),
@@ -302,155 +546,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) { Widget _buildUnindexedFilesEntry(WidgetRef ref) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -485,7 +580,10 @@ class FileListView extends HookConsumerWidget {
ValueNotifier<String> currentPath, ValueNotifier<String> currentPath,
) { ) {
return Card( 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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Column( child: Column(

View File

@@ -1,5 +1,4 @@
import 'dart:math' as math; import 'dart:math' as math;
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';
@@ -29,7 +28,8 @@ class UploadOverlay extends HookConsumerWidget {
.toList() .toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Newest first ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Newest first
final isVisible = activeTasks.isNotEmpty; final isVisibleOverride = useState<bool?>(null);
final isVisible = isVisibleOverride.value ?? activeTasks.isNotEmpty;
final slideController = useAnimationController( final slideController = useAnimationController(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
); );
@@ -63,6 +63,7 @@ class UploadOverlay extends HookConsumerWidget {
position: slideAnimation, position: slideAnimation,
child: _UploadOverlayContent( child: _UploadOverlayContent(
activeTasks: activeTasks, activeTasks: activeTasks,
onVisibilityChanged: (bool? v) => isVisibleOverride.value = v,
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom), ).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
), ),
); );
@@ -71,12 +72,15 @@ class UploadOverlay extends HookConsumerWidget {
class _UploadOverlayContent extends HookConsumerWidget { class _UploadOverlayContent extends HookConsumerWidget {
final List<DriveTask> activeTasks; final List<DriveTask> activeTasks;
final void Function(bool?) onVisibilityChanged;
const _UploadOverlayContent({required this.activeTasks}); const _UploadOverlayContent({
required this.activeTasks,
required this.onVisibilityChanged,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = useState(false);
final animationController = useAnimationController( final animationController = useAnimationController(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
initialValue: 0.0, initialValue: 0.0,
@@ -90,12 +94,15 @@ class _UploadOverlayContent extends HookConsumerWidget {
CurvedAnimation(parent: animationController, curve: Curves.easeInOut), CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
); );
final isExpanded = useState(false);
useEffect(() { useEffect(() {
if (isExpanded.value) { if (isExpanded.value) {
animationController.forward(); animationController.forward();
} else { } else {
animationController.reverse(); animationController.reverse();
} }
onVisibilityChanged.call(isExpanded.value);
return null; return null;
}, [isExpanded.value]); }, [isExpanded.value]);
@@ -256,6 +263,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
ref ref
.read(uploadTasksProvider.notifier) .read(uploadTasksProvider.notifier)
.clearCompletedTasks(); .clearCompletedTasks();
isExpanded.value = false;
}, },
tileColor: tileColor:
Theme.of( Theme.of(