[client] Refactored ExplorerView

This commit is contained in:
Abhishek Shroff
2024-09-15 09:08:14 +05:30
parent cfb11c3f9a
commit 460a622959
13 changed files with 432 additions and 427 deletions

View 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:

View File

@@ -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(

View File

@@ -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)),
],
);
}

View 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));
}
}
}

View 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(),
);
}
}

View 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);
}
}

View File

@@ -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(

View 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());
});
},
),
),
);
}
}

View File

@@ -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)

View File

@@ -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));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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,
));
}
}