mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-01-07 20:20:58 -06:00
[client] Refactored ExplorerView
This commit is contained in:
3
client/devtools_options.yaml
Normal file
3
client/devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
@@ -25,10 +25,11 @@ class _PhylumAppState extends State<PhylumApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
key: ValueKey(widget.account.id),
|
||||
providers: [
|
||||
Provider.value(value: widget.account),
|
||||
StateNotifierProvider<ApiActionQueue, ApiActionQueueState>.value(key: ValueKey(widget.account.id), value: widget.account.actionQueue),
|
||||
StateNotifierProvider<NavForwardManager, NavForwardState>.value(key: ValueKey(widget.account.id), value: historyManager),
|
||||
StateNotifierProvider<ApiActionQueue, ApiActionQueueState>.value(value: widget.account.actionQueue),
|
||||
StateNotifierProvider<NavForwardManager, NavForwardState>.value(value: historyManager),
|
||||
StateNotifierProvider<DownloadManager, DownloadManagerState>(create: (context) => DownloadManager(widget.account))
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:phylum/app_shortcuts.dart';
|
||||
import 'package:phylum/ui/app/app_actions.dart';
|
||||
import 'package:phylum/ui/common/expandable_fab.dart';
|
||||
import 'package:phylum/ui/app/nav_list.dart';
|
||||
import 'package:phylum/ui/folder/folder_view.dart';
|
||||
import 'package:phylum/ui/common/expandable_fab.dart';
|
||||
import 'package:phylum/ui/explorer/explorer_view.dart';
|
||||
|
||||
class AppLayout extends StatelessWidget {
|
||||
final String folderId;
|
||||
@@ -72,7 +72,7 @@ class AppLayout extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// FolderHeriarchyView(),
|
||||
Expanded(child: FolderView(folderId: folderId)),
|
||||
Expanded(child: ExplorerView.create(folderId)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
159
client/lib/ui/explorer/explorer_actions.dart
Normal file
159
client/lib/ui/explorer/explorer_actions.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:phylum/app_shortcuts.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_delete.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_move.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_rename.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/ui/preview/resource_preview.dart';
|
||||
import 'package:phylum/util/dialogs.dart';
|
||||
import 'package:phylum/util/upload_utils.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:super_clipboard/super_clipboard.dart';
|
||||
|
||||
import 'explorer_view_controller.dart';
|
||||
|
||||
class ExplorerActions extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const ExplorerActions({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Actions(
|
||||
actions: {
|
||||
FocusUpIntent: CallbackAction<FocusUpIntent>(onInvoke: (i) {
|
||||
context.read<ExplorerViewController>().updateSelection((i) => i - 1, i.mode, true);
|
||||
return null;
|
||||
}),
|
||||
FocusDownIntent: CallbackAction<FocusDownIntent>(onInvoke: (i) {
|
||||
context.read<ExplorerViewController>().updateSelection((i) => i + 1, i.mode, true);
|
||||
return null;
|
||||
}),
|
||||
ToggleSelectionIntent: CallbackAction<ToggleSelectionIntent>(onInvoke: (i) {
|
||||
context.read<ExplorerViewController>().updateSelection((i) => i, SelectionMode.toggle, true);
|
||||
return null;
|
||||
}),
|
||||
SelectAllIntent: CallbackAction<SelectAllIntent>(onInvoke: (i) {
|
||||
context.read<ExplorerViewController>().selectAll(showFocus: true);
|
||||
return null;
|
||||
}),
|
||||
DismissIntent: CallbackAction<DismissIntent>(onInvoke: (i) {
|
||||
context.read<ExplorerViewController>().updateSelection((i) => i, SelectionMode.single, true);
|
||||
return null;
|
||||
}),
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: (i) {
|
||||
final r = context.read<ExplorerViewState>().selectedSingle;
|
||||
if (r == null) return;
|
||||
_openResource(context, r);
|
||||
return null;
|
||||
}),
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (i) {
|
||||
final selected = context.read<ExplorerViewState>().selected;
|
||||
_deleteResources(context, selected);
|
||||
return null;
|
||||
}),
|
||||
RenameIntent: CallbackAction<RenameIntent>(onInvoke: (i) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final r = context.read<ExplorerViewState>().selectedSingle;
|
||||
if (r == null) return;
|
||||
final name = await showInputDialog(context, title: 'Rename', preset: r.name);
|
||||
if (name != null) {
|
||||
account.addAction(ResourceRenameAction(r: r, name: name));
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
CopyToClipboardIntent: CallbackAction<CopyToClipboardIntent>(onInvoke: (i) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final selected = context.read<ExplorerViewState>().selected;
|
||||
final items = selected.map((r) {
|
||||
final uri = account.api.createUriBuilder('/open');
|
||||
uri.queryParameters['id'] = r.id;
|
||||
if (i.cut) {
|
||||
uri.queryParameters['cut'] = 'y';
|
||||
}
|
||||
final uriData = Formats.uri(NamedUri(uri.build(), name: r.name));
|
||||
return DataWriterItem(suggestedName: r.name)..add(uriData);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${items.length} items ${i.cut ? 'cut' : 'copied'} to clipboard')));
|
||||
await SystemClipboard.instance?.write(items);
|
||||
return null;
|
||||
}),
|
||||
PasteFromClipboardIntent: CallbackAction<PasteFromClipboardIntent>(onInvoke: (i) async {
|
||||
// TODO: Move to top-level nav
|
||||
final account = context.read<PhylumAccount>();
|
||||
final folderId = context.read<ExplorerViewController>().folderId;
|
||||
final openUri = account.api.createUri('/open');
|
||||
final clipboard = SystemClipboard.instance;
|
||||
if (clipboard == null) {
|
||||
return;
|
||||
}
|
||||
final reader = await clipboard.read();
|
||||
final paths = <String>[];
|
||||
final cutResources = <Resource>[];
|
||||
final copyResources = <Resource>[];
|
||||
for (final item in reader.items) {
|
||||
final c = Completer<String?>();
|
||||
item.getValue(Formats.fileUri, (value) => c.complete(value?.toFilePath()), onError: (value) => c.complete(null));
|
||||
final filePath = await c.future;
|
||||
if (filePath != null) {
|
||||
paths.add(filePath);
|
||||
} else {
|
||||
final c = Completer<Uri?>();
|
||||
item.getValue(Formats.uri, (value) {
|
||||
c.complete(value?.uri);
|
||||
}, onError: (value) => c.complete(null));
|
||||
final uri = await c.future;
|
||||
if (uri != null) {
|
||||
if (uri.authority == openUri.authority && uri.path == openUri.path && uri.queryParameters.containsKey('id')) {
|
||||
final resourceId = uri.queryParameters['id']!;
|
||||
final cut = uri.queryParameters.containsKey('cut');
|
||||
final resource = await account.resourceRepository.getResource(resourceId);
|
||||
if (resource != null) {
|
||||
(cut ? cutResources : copyResources).add(resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
uploadRecursive(context, folderId, paths);
|
||||
final parent = await account.resourceRepository.getResource(folderId);
|
||||
if (parent != null) {
|
||||
for (final r in cutResources) {
|
||||
account.addAction(ResourceMoveAction(r: r, parent: parent));
|
||||
}
|
||||
}
|
||||
|
||||
await SystemClipboard.instance?.write([]);
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _openResource(BuildContext context, Resource r) {
|
||||
if (r.dir) {
|
||||
context.pushNamed('folder', pathParameters: {'id': r.id});
|
||||
} else {
|
||||
ResourcePreview.showPreview(context, r.id);
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteResources(BuildContext context, Iterable<Resource> resources) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
// TOOD: #folderconfirmation
|
||||
final confirm =
|
||||
await showAlertDialog(context, title: 'Delete selected files?', barrierDismissible: true, positiveText: 'YES', negativeText: 'NO') ?? false;
|
||||
|
||||
if (!confirm) return;
|
||||
for (final r in resources) {
|
||||
account.addAction(ResourceDeleteAction(r: r));
|
||||
}
|
||||
}
|
||||
}
|
||||
53
client/lib/ui/explorer/explorer_view.dart
Normal file
53
client/lib/ui/explorer/explorer_view.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'explorer_view_controller.dart';
|
||||
import 'folder_empty_view.dart';
|
||||
import 'folder_list_view.dart';
|
||||
|
||||
class ExplorerView extends StatelessWidget {
|
||||
const ExplorerView._({super.key});
|
||||
|
||||
static Widget create(String folderId) {
|
||||
return Builder(builder: (context) {
|
||||
return ListTileTheme(
|
||||
data: ListTileThemeData(
|
||||
mouseCursor: const WidgetStatePropertyAll(SystemMouseCursors.basic),
|
||||
selectedTileColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
child: StateNotifierProvider<ExplorerViewController, ExplorerViewState>(
|
||||
create: (context) => ExplorerViewController(account: context.read<PhylumAccount>(), folderId: folderId),
|
||||
child: Actions(
|
||||
actions: {
|
||||
NextFocusIntent: CallbackAction<NextFocusIntent>(onInvoke: (i) => null),
|
||||
PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(onInvoke: (i) => null),
|
||||
},
|
||||
child: ExplorerView._(key: ValueKey(folderId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = context.select<ExplorerViewState, FolderEmptyViewType?>((state) {
|
||||
if (state.resources.isEmpty) {
|
||||
if (state.folder?.lastFetch == null) {
|
||||
return FolderEmptyViewType.loading;
|
||||
}
|
||||
return FolderEmptyViewType.noData;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (type != null) {
|
||||
return FolderEmptyView(type: type);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: context.read<ExplorerViewController>().refresh,
|
||||
child: const FolderListView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
client/lib/ui/explorer/explorer_view_controller.dart
Normal file
133
client/lib/ui/explorer/explorer_view_controller.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:phylum/app_shortcuts.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:state_notifier/state_notifier.dart';
|
||||
|
||||
class ExplorerViewState {
|
||||
final Resource? folder;
|
||||
final List<Resource> resources;
|
||||
final Set<String> selectedIds;
|
||||
final int focusIndex;
|
||||
final bool showFocus;
|
||||
final int selectionStartIndex;
|
||||
final bool dragging;
|
||||
|
||||
String get focusId => (focusIndex < 0 || focusIndex >= resources.length) ? '' : resources[focusIndex].id;
|
||||
Iterable<Resource> get selected => resources.where((r) => selectedIds.contains(r.id));
|
||||
Resource? get selectedSingle => selectedIds.length != 1 ? null : resources.where((r) => selectedIds.contains(r.id)).firstOrNull;
|
||||
|
||||
const ExplorerViewState({
|
||||
this.folder,
|
||||
this.resources = const [],
|
||||
this.selectedIds = const {},
|
||||
this.focusIndex = -1,
|
||||
this.showFocus = false,
|
||||
this.selectionStartIndex = -1,
|
||||
this.dragging = false,
|
||||
});
|
||||
|
||||
ExplorerViewState copyWith({
|
||||
Resource? folder,
|
||||
List<Resource>? resources,
|
||||
Set<String>? selectedIds,
|
||||
int? focusIndex,
|
||||
bool? showFocus,
|
||||
int? selectionStartIndex,
|
||||
bool? dragging,
|
||||
}) =>
|
||||
ExplorerViewState(
|
||||
folder: folder ?? this.folder,
|
||||
resources: resources ?? this.resources,
|
||||
selectedIds: selectedIds ?? this.selectedIds,
|
||||
focusIndex: focusIndex ?? this.focusIndex,
|
||||
showFocus: showFocus ?? this.showFocus,
|
||||
selectionStartIndex: selectionStartIndex ?? this.selectionStartIndex,
|
||||
);
|
||||
|
||||
bool isSelected(String id) {
|
||||
return selectedIds.contains(id);
|
||||
}
|
||||
}
|
||||
|
||||
class ExplorerViewController extends StateNotifier<ExplorerViewState> {
|
||||
final PhylumAccount account;
|
||||
final String folderId;
|
||||
late final StreamSubscription<Resource?> folderSubscription;
|
||||
late final StreamSubscription<List<Resource>> childrenSubscription;
|
||||
|
||||
ExplorerViewController({required this.account, required this.folderId}) : super(const ExplorerViewState()) {
|
||||
folderSubscription = account.resourceRepository.watchResource(folderId).listen((e) => state = state.copyWith(folder: e));
|
||||
childrenSubscription = account.resourceRepository.watchChildren(folderId).listen((e) => _updateResourceList(e));
|
||||
}
|
||||
|
||||
void _updateResourceList(List<Resource> resources) {
|
||||
final extras = Set.of(state.selectedIds);
|
||||
extras.removeAll(state.resources.map((r) => r.id));
|
||||
final selectedIds = Set.of(state.selectedIds);
|
||||
selectedIds.removeAll(extras);
|
||||
|
||||
// selection.
|
||||
final focusIndex = resources.indexWhere((r) => r.id == state.focusId);
|
||||
|
||||
state = state.copyWith(
|
||||
resources: resources,
|
||||
focusIndex: focusIndex,
|
||||
selectedIds: selectedIds,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
folderSubscription.cancel();
|
||||
childrenSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void setDragging(bool dragging) {
|
||||
state = state.copyWith(dragging: dragging);
|
||||
}
|
||||
|
||||
void selectAll({required bool showFocus}) {
|
||||
state = state.copyWith(selectedIds: Set.of(state.resources.map((r) => r.id)), showFocus: showFocus);
|
||||
}
|
||||
|
||||
void updateSelection(int Function(int) indexFn, SelectionMode mode, bool highlight) {
|
||||
Set<String>? selectedIds;
|
||||
int index = indexFn(state.focusIndex);
|
||||
index = min(state.resources.length - 1, max(0, index));
|
||||
int selectionStartIndex = state.selectionStartIndex;
|
||||
switch (mode) {
|
||||
case SelectionMode.range:
|
||||
if (selectionStartIndex == -1) {
|
||||
selectionStartIndex = index;
|
||||
}
|
||||
selectedIds = Set.of(state.resources.getRange(min(selectionStartIndex, index), max(selectionStartIndex, index) + 1).map((r) => r.id));
|
||||
break;
|
||||
case SelectionMode.single:
|
||||
selectedIds = {state.resources[index].id};
|
||||
selectionStartIndex = index;
|
||||
break;
|
||||
case SelectionMode.toggle:
|
||||
selectedIds = Set.of(state.selectedIds);
|
||||
if (!selectedIds.add(state.resources[index].id)) {
|
||||
selectedIds.remove(state.resources[index].id);
|
||||
}
|
||||
selectionStartIndex = index;
|
||||
break;
|
||||
case SelectionMode.multi:
|
||||
break;
|
||||
}
|
||||
state = state.copyWith(
|
||||
selectedIds: selectedIds,
|
||||
focusIndex: index,
|
||||
showFocus: highlight,
|
||||
);
|
||||
}
|
||||
|
||||
Future refresh() {
|
||||
return account.resourceRepository.requestResource(folderId);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'explorer_view_controller.dart';
|
||||
|
||||
enum FolderEmptyViewType {
|
||||
loading,
|
||||
@@ -8,9 +11,8 @@ enum FolderEmptyViewType {
|
||||
|
||||
class FolderEmptyView extends StatelessWidget {
|
||||
final FolderEmptyViewType type;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
const FolderEmptyView({super.key, required this.type, required this.onRefresh});
|
||||
const FolderEmptyView({super.key, required this.type});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -18,7 +20,7 @@ class FolderEmptyView extends StatelessWidget {
|
||||
autofocus: true,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
onRefresh: context.read<ExplorerViewController>().refresh,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
62
client/lib/ui/explorer/folder_list_view.dart
Normal file
62
client/lib/ui/explorer/folder_list_view.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:phylum/app_shortcuts.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'explorer_actions.dart';
|
||||
import 'explorer_view_controller.dart';
|
||||
import 'resource_details_row.dart';
|
||||
|
||||
class FolderListView extends StatefulWidget {
|
||||
const FolderListView({super.key});
|
||||
|
||||
@override
|
||||
State<FolderListView> createState() => _FolderListViewState();
|
||||
}
|
||||
|
||||
class _FolderListViewState extends State<FolderListView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resources = context.select<ExplorerViewState, List<Resource>>((state) => state.resources);
|
||||
return ExplorerActions(
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
descendantsAreFocusable: false,
|
||||
child: ListView.builder(
|
||||
itemCount: resources.length,
|
||||
itemBuilder: (context, index) {
|
||||
final r = resources[index];
|
||||
bool deferHandling = false;
|
||||
return ResourceDetailsRow(
|
||||
r: r,
|
||||
onTapDown: (details) {
|
||||
Focus.maybeOf(context)?.requestFocus();
|
||||
final mode = HardwareKeyboard.instance.isControlPressed
|
||||
? SelectionMode.toggle
|
||||
: HardwareKeyboard.instance.isShiftPressed
|
||||
? SelectionMode.range
|
||||
: SelectionMode.single;
|
||||
if (context.read<ExplorerViewState>().isSelected(r.id) && mode == SelectionMode.single) {
|
||||
deferHandling = true;
|
||||
context.read<ExplorerViewController>().updateSelection((_) => index, SelectionMode.multi, false);
|
||||
} else {
|
||||
deferHandling = false;
|
||||
context.read<ExplorerViewController>().updateSelection((_) => index, mode, false);
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (deferHandling) {
|
||||
context.read<ExplorerViewController>().updateSelection((_) => index, SelectionMode.single, false);
|
||||
}
|
||||
},
|
||||
onDoubleTap: () {
|
||||
context.read<ExplorerViewController>().updateSelection((_) => index, SelectionMode.single, false);
|
||||
Actions.maybeInvoke(context, const ActivateIntent());
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_move.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/ui/folder/folder_selection_manager.dart';
|
||||
import 'package:phylum/ui/folder/resource_options_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'explorer_view_controller.dart';
|
||||
import 'resource_options_dialog.dart';
|
||||
|
||||
const _draggableDataSelected = '__selected';
|
||||
|
||||
class ResourceDetailsRow extends StatefulWidget {
|
||||
@@ -29,7 +30,7 @@ class _ResourceDetailsRowState extends State<ResourceDetailsRow> {
|
||||
? DragTarget(
|
||||
builder: (context, candidate, rejected) => buildRow(context),
|
||||
onWillAcceptWithDetails: (details) {
|
||||
if (details.data == _draggableDataSelected && context.read<FolderSelectionState>().selected.contains(widget.r.id)) {
|
||||
if (details.data == _draggableDataSelected && context.read<ExplorerViewState>().selectedIds.contains(widget.r.id)) {
|
||||
return false;
|
||||
}
|
||||
dropTargetActive = true;
|
||||
@@ -41,8 +42,7 @@ class _ResourceDetailsRowState extends State<ResourceDetailsRow> {
|
||||
onAcceptWithDetails: (details) async {
|
||||
if (details.data == _draggableDataSelected) {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final selectedIds = context.read<FolderSelectionState>().selected;
|
||||
final selected = await account.resourceRepository.getResources(selectedIds);
|
||||
final selected = context.read<ExplorerViewState>().selected;
|
||||
for (final res in selected) {
|
||||
account.addAction(ResourceMoveAction(r: res, parent: widget.r));
|
||||
}
|
||||
@@ -58,14 +58,14 @@ class _ResourceDetailsRowState extends State<ResourceDetailsRow> {
|
||||
data: _draggableDataSelected,
|
||||
dragAnchorStrategy: pointerDragAnchorStrategy,
|
||||
onDragStarted: () {
|
||||
context.read<FolderSelectionManager>().update(dragging: true);
|
||||
context.read<ExplorerViewController>().setDragging(true);
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
context.read<FolderSelectionManager>().update(dragging: false);
|
||||
context.read<ExplorerViewController>().setDragging(false);
|
||||
},
|
||||
feedback: Builder(builder: (ctx) {
|
||||
final theme = Theme.of(context);
|
||||
final count = context.read<FolderSelectionState>().selected.length;
|
||||
final count = context.read<ExplorerViewState>().selectedIds.length;
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(side: BorderSide(color: theme.colorScheme.primary), borderRadius: BorderRadius.circular(4.0)),
|
||||
elevation: 16.0,
|
||||
@@ -92,9 +92,9 @@ class _ResourceDetailsRowState extends State<ResourceDetailsRow> {
|
||||
}
|
||||
|
||||
Widget buildListTile(BuildContext context) {
|
||||
final showBorder = context.select<FolderSelectionState, bool>((state) => state.focusId == widget.r.id && state.showFocus);
|
||||
final highlight = context.select<FolderSelectionState, bool>((state) => state.isSelected(widget.r.id));
|
||||
final dim = context.select<FolderSelectionState, bool>((state) => state.isSelected(widget.r.id) && state.dragging);
|
||||
final showBorder = context.select<ExplorerViewState, bool>((state) => state.focusId == widget.r.id && state.showFocus);
|
||||
final highlight = context.select<ExplorerViewState, bool>((state) => state.isSelected(widget.r.id));
|
||||
final dim = context.select<ExplorerViewState, bool>((state) => state.isSelected(widget.r.id) && state.dragging);
|
||||
final theme = Theme.of(context);
|
||||
final border = dropTargetActive
|
||||
? BorderSide(color: theme.colorScheme.secondary, width: 2.0)
|
||||
@@ -1,279 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:phylum/app_shortcuts.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_delete.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_move.dart';
|
||||
import 'package:phylum/libphylum/actions/action_resource_rename.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/ui/folder/folder_selection_manager.dart';
|
||||
import 'package:phylum/ui/folder/resource_details_row.dart';
|
||||
import 'package:phylum/ui/preview/resource_preview.dart';
|
||||
import 'package:phylum/util/upload_utils.dart';
|
||||
import 'package:phylum/util/dialogs.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:super_clipboard/super_clipboard.dart';
|
||||
|
||||
class FolderContentsView extends StatefulWidget {
|
||||
final String folderId;
|
||||
final List<Resource> resources;
|
||||
|
||||
FolderContentsView({required this.folderId, required this.resources}) : super(key: ValueKey(folderId));
|
||||
|
||||
@override
|
||||
State<FolderContentsView> createState() => _FolderContentsViewState();
|
||||
}
|
||||
|
||||
class _FolderContentsViewState extends State<FolderContentsView> {
|
||||
late List<Resource> resources;
|
||||
int focusIndex = -1;
|
||||
int selectionStartIndex = -1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
resources = widget.resources;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FolderContentsView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final selection = context.read<FolderSelectionState>();
|
||||
|
||||
final extras = Set.of(selection.selected);
|
||||
extras.removeAll(widget.resources);
|
||||
final selected = Set.of(selection.selected);
|
||||
selected.removeAll(extras);
|
||||
|
||||
// selection.
|
||||
focusIndex = widget.resources.indexWhere((r) => r.id == selection.focusId);
|
||||
WidgetsBinding.instance.addPostFrameCallback((duration) => context.read<FolderSelectionManager>().update(
|
||||
focusId: focusIndex == -1 ? "" : null,
|
||||
selected: selected,
|
||||
));
|
||||
|
||||
setState(() => resources = widget.resources);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Actions(
|
||||
actions: {
|
||||
FocusUpIntent: CallbackAction<FocusUpIntent>(onInvoke: (i) {
|
||||
if (resources.isEmpty) return;
|
||||
final index = focusIndex == -1
|
||||
? (resources.length - 1)
|
||||
: focusIndex > 0
|
||||
? min(focusIndex - 1, resources.length - 1)
|
||||
: 0;
|
||||
updateSelection(index, i.mode, true);
|
||||
return null;
|
||||
}),
|
||||
FocusDownIntent: CallbackAction<FocusDownIntent>(onInvoke: (i) {
|
||||
if (resources.isEmpty) return;
|
||||
final index = min(focusIndex + 1, resources.length - 1);
|
||||
updateSelection(index, i.mode, true);
|
||||
return null;
|
||||
}),
|
||||
ToggleSelectionIntent: CallbackAction<ToggleSelectionIntent>(onInvoke: (i) {
|
||||
updateSelection(focusIndex, SelectionMode.toggle, true);
|
||||
return null;
|
||||
}),
|
||||
SelectAllIntent: CallbackAction<SelectAllIntent>(onInvoke: (i) {
|
||||
context.read<FolderSelectionManager>().update(selected: Set.of(resources.map((r) => r.id)), showFocus: true);
|
||||
return null;
|
||||
}),
|
||||
DismissIntent: CallbackAction<DismissIntent>(onInvoke: (i) {
|
||||
if (focusIndex >= 0) {
|
||||
updateSelection(focusIndex, SelectionMode.single, true);
|
||||
} else {
|
||||
context.read<FolderSelectionManager>().update(selected: const {}, showFocus: true);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: (i) {
|
||||
open(resources[focusIndex]);
|
||||
return null;
|
||||
}),
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (i) {
|
||||
deleteSelected();
|
||||
return null;
|
||||
}),
|
||||
RenameIntent: CallbackAction<RenameIntent>(onInvoke: (i) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final selectedIds = context.read<FolderSelectionState>().selected;
|
||||
if (selectedIds.length != 1) return;
|
||||
final selected = resources.where((r) => selectedIds.contains(r.id)).firstOrNull;
|
||||
if (selected == null) return;
|
||||
final name = await showInputDialog(context, title: 'Rename', preset: selected.name);
|
||||
if (name != null) {
|
||||
account.addAction(ResourceRenameAction(r: selected, name: name));
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
CopyToClipboardIntent: CallbackAction<CopyToClipboardIntent>(onInvoke: (i) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final selectedIds = context.read<FolderSelectionState>().selected;
|
||||
if (selectedIds.isEmpty) return;
|
||||
final selected = resources.where((r) => selectedIds.contains(r.id)).toList(growable: false);
|
||||
final items = selected.map((r) {
|
||||
final uri = account.api.createUriBuilder('/open');
|
||||
uri.queryParameters['id'] = r.id;
|
||||
if (i.cut) {
|
||||
uri.queryParameters['cut'] = 'y';
|
||||
}
|
||||
final uriData = Formats.uri(NamedUri(uri.build(), name: r.name));
|
||||
return DataWriterItem(suggestedName: r.name)..add(uriData);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${items.length} items ${i.cut ? 'cut' : 'copied'} to clipboard')));
|
||||
await SystemClipboard.instance?.write(items);
|
||||
return null;
|
||||
}),
|
||||
PasteFromClipboardIntent: CallbackAction<PasteFromClipboardIntent>(onInvoke: (i) async {
|
||||
// TODO: Move to top-level nav
|
||||
final account = context.read<PhylumAccount>();
|
||||
final openUri = account.api.createUri('/open');
|
||||
final clipboard = SystemClipboard.instance;
|
||||
if (clipboard == null) {
|
||||
return;
|
||||
}
|
||||
final reader = await clipboard.read();
|
||||
final paths = <String>[];
|
||||
final cutResources = <Resource>[];
|
||||
final copyResources = <Resource>[];
|
||||
for (final item in reader.items) {
|
||||
final c = Completer<String?>();
|
||||
item.getValue(Formats.fileUri, (value) => c.complete(value?.toFilePath()), onError: (value) => c.complete(null));
|
||||
final filePath = await c.future;
|
||||
if (filePath != null) {
|
||||
paths.add(filePath);
|
||||
} else {
|
||||
final c = Completer<Uri?>();
|
||||
item.getValue(Formats.uri, (value) {
|
||||
c.complete(value?.uri);
|
||||
}, onError: (value) => c.complete(null));
|
||||
final uri = await c.future;
|
||||
if (uri != null) {
|
||||
if (uri.authority == openUri.authority && uri.path == openUri.path && uri.queryParameters.containsKey('id')) {
|
||||
final resourceId = uri.queryParameters['id']!;
|
||||
final cut = uri.queryParameters.containsKey('cut');
|
||||
final resource = await account.resourceRepository.getResource(resourceId);
|
||||
if (resource != null) {
|
||||
(cut ? cutResources : copyResources).add(resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
uploadRecursive(context, widget.folderId, paths);
|
||||
final parent = await account.resourceRepository.getResource(widget.folderId);
|
||||
if (parent != null) {
|
||||
for (final r in cutResources) {
|
||||
account.addAction(ResourceMoveAction(r: r, parent: parent));
|
||||
}
|
||||
}
|
||||
|
||||
await SystemClipboard.instance?.write([]);
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
descendantsAreFocusable: false,
|
||||
child: ListView.builder(
|
||||
itemCount: resources.length,
|
||||
itemBuilder: (context, index) {
|
||||
final r = resources[index];
|
||||
bool deferHandling = false;
|
||||
return ResourceDetailsRow(
|
||||
r: r,
|
||||
onTapDown: (details) {
|
||||
Focus.maybeOf(context)?.requestFocus();
|
||||
final mode = HardwareKeyboard.instance.isControlPressed
|
||||
? SelectionMode.toggle
|
||||
: HardwareKeyboard.instance.isShiftPressed
|
||||
? SelectionMode.range
|
||||
: SelectionMode.single;
|
||||
if (context.read<FolderSelectionState>().isSelected(r.id) && mode == SelectionMode.single) {
|
||||
deferHandling = true;
|
||||
context.read<FolderSelectionManager>().update(focusId: r.id, showFocus: false);
|
||||
} else {
|
||||
deferHandling = false;
|
||||
updateSelection(index, mode, false);
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (deferHandling) {
|
||||
updateSelection(index, SelectionMode.single, false);
|
||||
}
|
||||
},
|
||||
onDoubleTap: () => open(r));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateSelection(int index, SelectionMode mode, bool highlight) {
|
||||
Set<String>? selected;
|
||||
switch (mode) {
|
||||
case SelectionMode.range:
|
||||
if (selectionStartIndex == -1) {
|
||||
selectionStartIndex = index;
|
||||
}
|
||||
selected = Set.of(resources.getRange(min(selectionStartIndex, index), max(selectionStartIndex, index) + 1).map((r) => r.id));
|
||||
break;
|
||||
case SelectionMode.single:
|
||||
selected = {resources[index].id};
|
||||
selectionStartIndex = index;
|
||||
break;
|
||||
case SelectionMode.toggle:
|
||||
selected = Set.of(context.read<FolderSelectionState>().selected);
|
||||
if (!selected.add(resources[index].id)) {
|
||||
selected.remove(resources[index].id);
|
||||
}
|
||||
selectionStartIndex = index;
|
||||
break;
|
||||
case SelectionMode.multi:
|
||||
break;
|
||||
}
|
||||
focusIndex = index;
|
||||
context
|
||||
.read<FolderSelectionManager>()
|
||||
.update(selected: selected, focusId: index < resources.length ? resources[index].id : null, showFocus: highlight);
|
||||
}
|
||||
|
||||
void open(Resource r) {
|
||||
if (r.dir) {
|
||||
context.pushNamed('folder', pathParameters: {'id': r.id});
|
||||
} else {
|
||||
ResourcePreview.showPreview(context, r.id);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteSelected() async {
|
||||
final selectedIds = context.read<FolderSelectionState>().selected;
|
||||
if (selectedIds.isEmpty) return;
|
||||
final account = context.read<PhylumAccount>();
|
||||
final selected = resources.where((r) => selectedIds.contains(r.id)).toList(growable: false);
|
||||
final confirm = await showAlertDialog(context,
|
||||
title: 'Delete ${selectedIds.length} files?', barrierDismissible: true, positiveText: 'YES', negativeText: 'NO') ??
|
||||
false;
|
||||
|
||||
if (!confirm) return;
|
||||
for (final r in selected) {
|
||||
account.addAction(ResourceDeleteAction(r: r));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:state_notifier/state_notifier.dart';
|
||||
|
||||
class FolderSelectionState {
|
||||
final Set<String> selected;
|
||||
final String focusId;
|
||||
final bool showFocus;
|
||||
final bool dragging;
|
||||
|
||||
FolderSelectionState({required this.selected, required this.focusId, required this.showFocus, required this.dragging});
|
||||
|
||||
bool isSelected(String id) {
|
||||
return selected.contains(id);
|
||||
}
|
||||
}
|
||||
|
||||
class FolderSelectionManager extends StateNotifier<FolderSelectionState> {
|
||||
FolderSelectionManager() : super(FolderSelectionState(selected: {}, focusId: "", showFocus: false, dragging: false));
|
||||
|
||||
void update({
|
||||
Set<String>? selected,
|
||||
String? focusId,
|
||||
bool? showFocus,
|
||||
bool? dragging,
|
||||
}) {
|
||||
state = FolderSelectionState(
|
||||
selected: selected ?? state.selected,
|
||||
focusId: focusId ?? state.focusId,
|
||||
showFocus: showFocus ?? state.showFocus,
|
||||
dragging: dragging ?? state.dragging);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/ui/folder/folder_contents_view.dart';
|
||||
import 'package:phylum/ui/folder/folder_empty_view.dart';
|
||||
import 'package:phylum/ui/folder/folder_selection_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FolderView extends StatelessWidget {
|
||||
final String folderId;
|
||||
|
||||
const FolderView({super.key, required this.folderId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return StateNotifierProvider<FolderSelectionManager, FolderSelectionState>(
|
||||
create: (context) => FolderSelectionManager(),
|
||||
child: Actions(
|
||||
actions: {
|
||||
NextFocusIntent: CallbackAction<NextFocusIntent>(onInvoke: (i) => null),
|
||||
PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(onInvoke: (i) => null),
|
||||
},
|
||||
child: ListTileTheme(
|
||||
data: ListTileThemeData(
|
||||
mouseCursor: const WidgetStatePropertyAll(SystemMouseCursors.basic),
|
||||
selectedTileColor: theme.colorScheme.primaryContainer,
|
||||
),
|
||||
child: FolderWatcher(folderId: folderId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FolderWatcher extends StatelessWidget {
|
||||
final String folderId;
|
||||
|
||||
const FolderWatcher({super.key, required this.folderId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FolderContentsListener(folderId: folderId);
|
||||
}
|
||||
|
||||
class FolderContentsListener extends StatefulWidget {
|
||||
final String folderId;
|
||||
|
||||
FolderContentsListener({required this.folderId}) : super(key: ValueKey(folderId));
|
||||
|
||||
@override
|
||||
State<FolderContentsListener> createState() => _FolderContentsListenerState();
|
||||
}
|
||||
|
||||
class _FolderContentsListenerState extends State<FolderContentsListener> {
|
||||
Resource? resource;
|
||||
List<Resource> children = const [];
|
||||
StreamSubscription<Resource?>? resourceSubscription;
|
||||
StreamSubscription<List<Resource>>? childrenSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
final account = context.read<PhylumAccount>();
|
||||
resourceSubscription = account.resourceRepository.watchResource(widget.folderId).listen((e) => setState(() => resource = e));
|
||||
childrenSubscription = account.resourceRepository.watchChildren(widget.folderId).listen((e) => setState(() => children = e));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
resourceSubscription?.cancel();
|
||||
childrenSubscription?.cancel();
|
||||
}
|
||||
|
||||
Future _refresh() {
|
||||
return context.read<PhylumAccount>().resourceRepository.requestResource(widget.folderId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (children.isEmpty) {
|
||||
if (resource?.lastFetch == null) {
|
||||
return FolderEmptyView(type: FolderEmptyViewType.loading, onRefresh: _refresh);
|
||||
}
|
||||
return FolderEmptyView(type: FolderEmptyViewType.noData, onRefresh: _refresh);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FolderContentsView(
|
||||
folderId: widget.folderId,
|
||||
resources: children,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user