From 98e988c13566f1d68832d767107ee63a0a4289df Mon Sep 17 00:00:00 2001 From: Abhishek Shroff Date: Wed, 4 Sep 2024 14:22:03 +0530 Subject: [PATCH] [client] Multi-select --- .../lib/ui/folder/folder_contents_view.dart | 266 +++++++++--------- .../ui/folder/folder_selection_manager.dart | 30 ++ .../ui/folder/folder_shortcuts_manager.dart | 72 +++++ .../lib/ui/folder/resource_details_row.dart | 49 ++++ 4 files changed, 283 insertions(+), 134 deletions(-) create mode 100644 client/lib/ui/folder/folder_selection_manager.dart create mode 100644 client/lib/ui/folder/folder_shortcuts_manager.dart create mode 100644 client/lib/ui/folder/resource_details_row.dart diff --git a/client/lib/ui/folder/folder_contents_view.dart b/client/lib/ui/folder/folder_contents_view.dart index d0a0b3aa..7357a86c 100644 --- a/client/lib/ui/folder/folder_contents_view.dart +++ b/client/lib/ui/folder/folder_contents_view.dart @@ -1,9 +1,13 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; -import 'package:flutter/services.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/libphylum/requests/resource_detail_request.dart'; -import 'package:phylum/ui/folder/resource_options_dialog.dart'; +import 'package:phylum/ui/folder/folder_selection_manager.dart'; +import 'package:phylum/ui/folder/folder_shortcuts_manager.dart'; +import 'package:phylum/ui/folder/resource_details_row.dart'; import 'package:provider/provider.dart'; import 'folder_navigator_stack.dart'; @@ -17,35 +21,22 @@ class FolderContentsView extends StatefulWidget { State createState() => _FolderContentsViewState(); } -class FolderShortcutManager extends ShortcutManager { - FolderShortcutManager() - : super(shortcuts: const { - SingleActivator(LogicalKeyboardKey.arrowUp): FocusUpIntent(), - SingleActivator(LogicalKeyboardKey.keyK): FocusUpIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): FocusDownIntent(), - SingleActivator(LogicalKeyboardKey.enter): OpenIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): NavUpIntent(), - }); - - @override - KeyEventResult handleKeypress(BuildContext context, KeyEvent event) { - if ((event.logicalKey == LogicalKeyboardKey.shiftLeft || event.logicalKey == LogicalKeyboardKey.shiftRight)) { - if (event is KeyDownEvent) { - print('Shift Pressed'); - } else if (event is KeyUpEvent) { - print('Shift Released'); - } - } - return super.handleKeypress(context, event); - } -} - class _FolderContentsViewState extends State { late final FocusNode _node; - final shortcutManager = FolderShortcutManager(); + late final shortcutManager = FolderShortcutManager( + shiftListener: (state) { + shiftDown = state; + }, + controlListener: (state) { + controlDown = state; + }, + ); final Map nodes = {}; int focusIndex = -1; + int selectionStartIndex = -1; List? resources; + bool shiftDown = false; + bool controlDown = false; @override void initState() { @@ -81,58 +72,122 @@ class _FolderContentsViewState extends State { child: RefreshIndicator( onRefresh: _refresh, child: Center( - child: StreamBuilder( - stream: account.datastore.db.managers.resources.filter((f) => f.parent.id.equals(widget.id) & f.deleted.equals(false)).watch(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Container(); - } - resources = snapshot.data!; - return Actions( - actions: { - FocusUpIntent: CallbackAction(onInvoke: (i) { - if (focusIndex == -1) { - focusIndex = resources!.length; - } - focusIndex = focusIndex > 0 ? focusIndex - 1 : 0; - getFocusNode(resources![focusIndex]).requestFocus(); - return null; - }), - FocusDownIntent: CallbackAction(onInvoke: (i) { - focusIndex = focusIndex < resources!.length - 1 ? focusIndex + 1 : resources!.length - 1; - getFocusNode(resources![focusIndex]).requestFocus(); - return null; - }), - OpenIntent: CallbackAction(onInvoke: (i) { - open(resources![focusIndex]); - return null; - }), - NavUpIntent: CallbackAction(onInvoke: (i) { - context.read().pop(); - return null; - }), - }, - child: Focus( - autofocus: true, - focusNode: _node, - child: ListView( - children: [ - for (final r in resources!.indexed) - Focus( - focusNode: getFocusNode(r.$2), - child: ResourceListTile( - r: r.$2, - onTap: () { - focusIndex = r.$1; - getFocusNode(r.$2).requestFocus(); - }, - onDoubleTap: () => open(r.$2)), - ), - ], + child: StateNotifierProvider( + create: (context) => FolderSelectionManager(), + child: StreamBuilder( + stream: account.datastore.db.managers.resources.filter((f) => f.parent.id.equals(widget.id) & f.deleted.equals(false)).watch(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + resources = snapshot.data!; + return Actions( + actions: { + FocusUpIntent: CallbackAction(onInvoke: (i) { + if (resources!.isEmpty) return; + final newIndex = focusIndex == -1 + ? resources!.length + : focusIndex > 0 + ? focusIndex - 1 + : 0; + final selectedResource = resources![newIndex]; + getFocusNode(selectedResource).requestFocus(); + switch (i.mode) { + case SelectionMode.range: + if (selectionStartIndex == -1) { + selectionStartIndex = newIndex; + } + final selected = Set.of( + resources!.getRange(min(selectionStartIndex, newIndex), max(selectionStartIndex, newIndex) + 1).map((r) => r.id)); + context.read().setSelected(selected); + case SelectionMode.single: + context.read().setSelected({selectedResource.id}); + selectionStartIndex = newIndex; + break; + case SelectionMode.multi: + break; + } + focusIndex = newIndex; + return null; + }), + FocusDownIntent: CallbackAction(onInvoke: (i) { + if (resources!.isEmpty) return; + final newIndex = focusIndex < resources!.length - 1 ? focusIndex + 1 : resources!.length - 1; + final selectedResource = resources![newIndex]; + getFocusNode(selectedResource).requestFocus(); + switch (i.mode) { + case SelectionMode.range: + if (selectionStartIndex == -1) { + selectionStartIndex = newIndex; + } + final selected = Set.of( + resources!.getRange(min(selectionStartIndex, newIndex), max(selectionStartIndex, newIndex) + 1).map((r) => r.id)); + context.read().setSelected(selected); + case SelectionMode.single: + context.read().setSelected({selectedResource.id}); + selectionStartIndex = newIndex; + break; + case SelectionMode.multi: + break; + } + focusIndex = newIndex; + return null; + }), + ToggleSelectionIntent: CallbackAction(onInvoke: (i) { + final selectedResource = resources![focusIndex]; + selectionStartIndex = context.read().toggleSelected(selectedResource.id) ? focusIndex : -1; + return null; + }), + OpenIntent: CallbackAction(onInvoke: (i) { + open(resources![focusIndex]); + return null; + }), + NavUpIntent: CallbackAction(onInvoke: (i) { + context.read().pop(); + return null; + }), + }, + child: Focus( + autofocus: true, + focusNode: _node, + onFocusChange: (hasFocus) { + shiftDown = false; + controlDown = false; + }, + child: ListView( + children: [ + for (final r in resources!.indexed) + Focus( + focusNode: getFocusNode(r.$2), + child: ResourceDetailsRow( + r: r.$2, + onTap: () { + final newIndex = r.$1; + if (controlDown) { + selectionStartIndex = context.read().toggleSelected(r.$2.id) ? newIndex : -1; + } else if (shiftDown) { + if (selectionStartIndex == -1) { + selectionStartIndex = newIndex; + } + final selected = Set.of(resources! + .getRange(min(selectionStartIndex, newIndex), max(selectionStartIndex, newIndex) + 1) + .map((r) => r.id)); + context.read().setSelected(selected); + } else { + selectionStartIndex = r.$1; + context.read().setSelected({r.$2.id}); + } + getFocusNode(r.$2).requestFocus(); + focusIndex = newIndex; + }, + onDoubleTap: () => open(r.$2)), + ), + ], + ), ), - ), - ); - }), + ); + }), + ), ), ), ), @@ -147,60 +202,3 @@ class _FolderContentsViewState extends State { return nodes.putIfAbsent(r.id, () => FocusNode(descendantsAreTraversable: false)); } } - -class FocusUpIntent extends Intent { - const FocusUpIntent(); -} - -class FocusDownIntent extends Intent { - const FocusDownIntent(); -} - -class OpenIntent extends Intent { - const OpenIntent(); -} - -class NavUpIntent extends Intent { - const NavUpIntent(); -} - -class ResourceListTile extends StatelessWidget { - final Resource r; - final Function()? onTap; - final Function()? onDoubleTap; - final Function()? onSecondaryTap; - - ResourceListTile({required this.r, this.onTap, this.onDoubleTap, this.onSecondaryTap}) : super(key: ValueKey(r.id)); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onTap?.call(), - onDoubleTap: () => onDoubleTap?.call(), - onSecondaryTap: () => onSecondaryTap?.call(), - child: Container( - decoration: Focus.isAt(context) - ? const BoxDecoration(border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2.0))) - : const BoxDecoration(border: Border.fromBorderSide(BorderSide(color: Colors.transparent, width: 2.0))), - padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0), - child: Row( - children: [r.getIcon(), Expanded(child: Text(r.name)), ResourceOptionsButton(r: r)], - ), - )); - } -} - -extension ResourceViewExtensions on Resource { - Icon getIcon() { - if (dir) return const Icon(Icons.folder); - final index = name.lastIndexOf('.'); - final ext = index > 0 ? name.substring(index + 1).toLowerCase() : ''; - return switch (ext) { - 'bmp' || 'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'heic' || 'heif' || 'svg' => const Icon(Icons.image), - 'webm' || 'avi' || 'mp4' || 'mov' => const Icon(Icons.movie), - 'mp3' || 'mp4' || 'm4a' || 'ogg' || 'oga' => const Icon(Icons.headphones), - 'txt' || 'csv' => const Icon(Icons.article), - _ => const Icon(Icons.insert_drive_file), - }; - } -} diff --git a/client/lib/ui/folder/folder_selection_manager.dart b/client/lib/ui/folder/folder_selection_manager.dart new file mode 100644 index 00000000..06b69626 --- /dev/null +++ b/client/lib/ui/folder/folder_selection_manager.dart @@ -0,0 +1,30 @@ +import 'package:state_notifier/state_notifier.dart'; + +class FolderSelectionState { + final Set selected; + + FolderSelectionState({required this.selected}); + + bool isSelected(String id) { + return selected.contains(id); + } +} + +class FolderSelectionManager extends StateNotifier { + FolderSelectionManager() : super(FolderSelectionState(selected: {})); + + void setSelected(Set ids) { + state = FolderSelectionState(selected: ids); + } + + bool toggleSelected(String id) { + final selected = Set.of(state.selected); + bool added = true; + if (!selected.add(id)) { + selected.remove(id); + added = false; + } + state = FolderSelectionState(selected: selected); + return added; + } +} diff --git a/client/lib/ui/folder/folder_shortcuts_manager.dart b/client/lib/ui/folder/folder_shortcuts_manager.dart new file mode 100644 index 00000000..07f67ee6 --- /dev/null +++ b/client/lib/ui/folder/folder_shortcuts_manager.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +enum SelectionMode { + single, + range, + multi, +} + +class ToggleSelectionIntent extends Intent { + const ToggleSelectionIntent(); +} + +class FocusUpIntent extends Intent { + final SelectionMode mode; + const FocusUpIntent(this.mode); +} + +class FocusDownIntent extends Intent { + final SelectionMode mode; + const FocusDownIntent(this.mode); +} + +class OpenIntent extends Intent { + const OpenIntent(); +} + +class NavUpIntent extends Intent { + const NavUpIntent(); +} + +class FolderShortcutManager extends ShortcutManager { + final Function(bool) shiftListener; + final Function(bool) controlListener; + + FolderShortcutManager({ + required this.shiftListener, + required this.controlListener, + }) : super(shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowUp): FocusUpIntent(SelectionMode.single), + SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): FocusUpIntent(SelectionMode.range), + SingleActivator(LogicalKeyboardKey.arrowUp, control: true): FocusUpIntent(SelectionMode.multi), + SingleActivator(LogicalKeyboardKey.arrowDown): FocusDownIntent(SelectionMode.single), + SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): FocusDownIntent(SelectionMode.range), + SingleActivator(LogicalKeyboardKey.arrowDown, control: true): FocusDownIntent(SelectionMode.multi), + SingleActivator(LogicalKeyboardKey.space, control: true): ToggleSelectionIntent(), + SingleActivator(LogicalKeyboardKey.enter): OpenIntent(), + SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): NavUpIntent(), + }); + + @override + KeyEventResult handleKeypress(BuildContext context, KeyEvent event) { + if (event.logicalKey == LogicalKeyboardKey.tab) { + return KeyEventResult.skipRemainingHandlers; + } + if ((event.logicalKey == LogicalKeyboardKey.shiftLeft || event.logicalKey == LogicalKeyboardKey.shiftRight)) { + if (event is KeyDownEvent) { + shiftListener(true); + } else if (event is KeyUpEvent) { + shiftListener(false); + } + } + if ((event.logicalKey == LogicalKeyboardKey.controlLeft || event.logicalKey == LogicalKeyboardKey.controlRight)) { + if (event is KeyDownEvent) { + controlListener(true); + } else if (event is KeyUpEvent) { + controlListener(false); + } + } + return super.handleKeypress(context, event); + } +} diff --git a/client/lib/ui/folder/resource_details_row.dart b/client/lib/ui/folder/resource_details_row.dart new file mode 100644 index 00000000..6e9e168f --- /dev/null +++ b/client/lib/ui/folder/resource_details_row.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:phylum/libphylum/db/db.dart'; +import 'package:phylum/ui/folder/folder_selection_manager.dart'; +import 'package:phylum/ui/folder/resource_options_dialog.dart'; +import 'package:provider/provider.dart'; + +class ResourceDetailsRow extends StatelessWidget { + final Resource r; + final Function()? onTap; + final Function()? onDoubleTap; + final Function()? onSecondaryTap; + + ResourceDetailsRow({required this.r, this.onTap, this.onDoubleTap, this.onSecondaryTap}) : super(key: ValueKey(r.id)); + + @override + Widget build(BuildContext context) { + bool selected = context.select((state) => state.isSelected(r.id)); + return GestureDetector( + onTapDown: (details) => onTap?.call(), + onDoubleTap: () => onDoubleTap?.call(), + onSecondaryTap: () => onSecondaryTap?.call(), + child: Container( + decoration: Focus.isAt(context) + ? BoxDecoration( + border: const Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2.0)), color: selected ? Colors.lightBlue : null) + : BoxDecoration( + border: const Border.fromBorderSide(BorderSide(color: Colors.transparent, width: 2.0)), color: selected ? Colors.lightBlue : null), + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0), + child: Row( + children: [r.getIcon(), Expanded(child: Text(r.name)), ResourceOptionsButton(r: r)], + ), + )); + } +} + +extension ResourceViewExtensions on Resource { + Icon getIcon() { + if (dir) return const Icon(Icons.folder); + final index = name.lastIndexOf('.'); + final ext = index > 0 ? name.substring(index + 1).toLowerCase() : ''; + return switch (ext) { + 'bmp' || 'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'heic' || 'heif' || 'svg' => const Icon(Icons.image), + 'webm' || 'avi' || 'mp4' || 'mov' => const Icon(Icons.movie), + 'mp3' || 'mp4' || 'm4a' || 'ogg' || 'oga' => const Icon(Icons.headphones), + 'txt' || 'csv' => const Icon(Icons.article), + _ => const Icon(Icons.insert_drive_file), + }; + } +}