From a29b62ebd5cf1a3c054708c6e68be8d0868ab5a8 Mon Sep 17 00:00:00 2001 From: David Hobley Date: Sun, 7 Apr 2024 20:58:46 +0100 Subject: [PATCH] massive refactor to deal with selected files in a much better manner. --- lib/interfaces/tag_handler.dart | 6 + lib/main.dart | 9 +- lib/misc/keyboard_handler.dart | 22 ++- lib/misc/utils.dart | 2 +- lib/models/folder_ui_settings.dart | 2 + lib/models/folder_ui_settings.freezed.dart | 2 +- lib/providers/contents/folder_contents.dart | 24 ++- lib/providers/contents/grid_contents.dart | 71 ++++----- lib/providers/contents/grid_tags.dart | 16 +- lib/providers/contents/pane_contents.dart | 37 ++--- lib/providers/contents/pane_tags.dart | 22 +++ .../contents/selected_grid_entities.dart | 44 +++--- .../contents/selected_grid_metadata.dart | 4 +- .../contents/selected_pane_entity.dart | 17 +++ lib/providers/file_events.dart | 4 +- lib/providers/location_update.dart | 1 + lib/providers/photo_location.dart | 4 +- lib/widgets/entity_context_menu.dart | 17 +-- lib/widgets/entity_preview.dart | 6 +- lib/widgets/folder_list.dart | 126 +++++++--------- lib/widgets/import_folder.dart | 7 +- lib/widgets/metadata/metadata_editor.dart | 141 +++++++++--------- lib/widgets/metadata/metadata_location.dart | 11 +- lib/widgets/navigation.dart | 107 ++++++------- .../navigation/navigation_favourites.dart | 2 +- .../navigation/navigation_mounted_drives.dart | 4 +- lib/widgets/navigation/navigation_space.dart | 2 +- lib/widgets/navigation/navigation_tags.dart | 6 +- lib/widgets/preview/image_preview.dart | 17 ++- lib/widgets/preview/video_preview.dart | 1 + lib/widgets/preview_grid.dart | 95 +++++++++--- lib/widgets/preview_pane.dart | 62 ++++++-- lib/widgets/shackleton.dart | 120 ++++++++++----- lib/widgets/shackleton_settings.dart | 2 +- lib/widgets/shackleton_statistics.dart | 2 +- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- pubspec.yaml | 28 ++-- test/metadata_test.dart | 15 ++ test/selected_entities_test.dart | 10 +- test/widget_test.dart | 16 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 46 files changed, 675 insertions(+), 435 deletions(-) diff --git a/lib/interfaces/tag_handler.dart b/lib/interfaces/tag_handler.dart index e69de29..3bbfd35 100644 --- a/lib/interfaces/tag_handler.dart +++ b/lib/interfaces/tag_handler.dart @@ -0,0 +1,6 @@ +import '../models/tag.dart'; + +abstract class TagHandler { + void removeTag(Tag tag); + void updateTags(String tags); +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index b5c000a..65d856b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:media_kit/media_kit.dart'; -import 'package:shackleton/repositories/app_settings_repository.dart'; +import 'package:window_manager/window_manager.dart'; import 'database/app_database.dart'; import 'misc/provider_logger.dart'; import 'providers/shackleton_theme.dart'; +import 'repositories/app_settings_repository.dart'; import 'widgets/shackleton.dart'; Future openDatabase() async { @@ -16,8 +17,12 @@ Future openDatabase() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); + await windowManager.ensureInitialized(); MediaKit.ensureInitialized(); + // TODO: Should we care about optimising portrait images which could be made marginally smaller? + // debugInvertOversizedImages = true; + await openDatabase(); runApp(ProviderScope( @@ -26,7 +31,7 @@ void main() async { } class ShackletonApp extends ConsumerWidget { - const ShackletonApp({Key? key}) : super(key: key); + const ShackletonApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/misc/keyboard_handler.dart b/lib/misc/keyboard_handler.dart index f12a7d4..df1298b 100644 --- a/lib/misc/keyboard_handler.dart +++ b/lib/misc/keyboard_handler.dart @@ -8,6 +8,7 @@ import '../interfaces/keyboard_callback.dart'; class KeyboardHandler { bool isIndividualMultiSelectionPressed = false; bool isBlockMultiSelectionPressed = false; + bool processModifierKeys = true; bool hasFocus = false; bool isEditing = false; KeyboardCallback keyboardCallback; @@ -50,16 +51,27 @@ class KeyboardHandler { } return true; - } else if (HardwareKeyboard.instance.isShiftPressed) { + } else if (processModifierKeys && HardwareKeyboard.instance.isShiftPressed) { if (event.logicalKey == LogicalKeyboardKey.tab) { isBlockMultiSelectionPressed = false; keyboardCallback.left(); - return true; - } - isBlockMultiSelectionPressed = true; + return true; + } else { + isBlockMultiSelectionPressed = true; + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + keyboardCallback.left(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + keyboardCallback.right(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + keyboardCallback.up(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + keyboardCallback.down(); + } - return true; + return true; + } } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { keyboardCallback.left(); diff --git a/lib/misc/utils.dart b/lib/misc/utils.dart index 541ae17..f30ef25 100644 --- a/lib/misc/utils.dart +++ b/lib/misc/utils.dart @@ -52,7 +52,7 @@ Future createZip(FileOfInterest folder, Set fil } String convertLatLng(double decimal, bool isLat) { - String degree = "${decimal.toString().split(".")[0]} deg"; + String degree = "${decimal.abs().toString().split(".")[0]} deg"; double minutesBeforeConversion = double.parse("0.${decimal.toString().split(".")[1]}"); String minutes = "${(minutesBeforeConversion * 60).toString().split('.')[0]}'"; diff --git a/lib/models/folder_ui_settings.dart b/lib/models/folder_ui_settings.dart index 2462966..de7b9ce 100644 --- a/lib/models/folder_ui_settings.dart +++ b/lib/models/folder_ui_settings.dart @@ -13,6 +13,8 @@ String _fseToJson(FileSystemEntity entity) => entity.path; bool _boolFromJson(int value) => value.isOdd; int _boolToJson(bool value) => value ? 1 : 0; +const String navigationFolder = '**navigation**'; + @freezed class FolderUISettings with _$FolderUISettings { const factory FolderUISettings({ diff --git a/lib/models/folder_ui_settings.freezed.dart b/lib/models/folder_ui_settings.freezed.dart index 8c24372..02bab6e 100644 --- a/lib/models/folder_ui_settings.freezed.dart +++ b/lib/models/folder_ui_settings.freezed.dart @@ -12,7 +12,7 @@ part of 'folder_ui_settings.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); FolderUISettings _$FolderUISettingsFromJson(Map json) { return _FolderUISettings.fromJson(json); diff --git a/lib/providers/contents/folder_contents.dart b/lib/providers/contents/folder_contents.dart index e11458d..c3d0e22 100644 --- a/lib/providers/contents/folder_contents.dart +++ b/lib/providers/contents/folder_contents.dart @@ -2,24 +2,27 @@ import 'dart:io'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../models/file_of_interest.dart'; -import '../providers/file_events.dart'; + +import '../../models/file_of_interest.dart'; +import '../../providers/contents/grid_contents.dart'; +import '../../providers/contents/selected_folder_contents.dart'; +import '../file_events.dart'; part 'folder_contents.g.dart'; enum EntitySortField { name, size, modified } enum EntitySortOrder { asc, desc } -@Riverpod(keepAlive: true) +@riverpod class FolderContents extends _$FolderContents { EntitySortField _defaultSort = EntitySortField.name; EntitySortOrder _defaultSortOrder = EntitySortOrder.asc; @override List build(Directory path) { - getFolderContents(path); watchFolder(path); - return state; + + return getFolderContents(path); } void add(FileOfInterest entity) { @@ -27,12 +30,13 @@ class FolderContents extends _$FolderContents { state = [...sort(entities, _defaultSort)]; } - void getFolderContents(Directory path) { + List getFolderContents(Directory path) { List files = []; for (var file in path.listSync()) { files.add(FileOfInterest(entity: file)); } - state = [...sort(files, _defaultSort)]; + + return sort(files, _defaultSort); } EntitySortField getSortField() { @@ -73,7 +77,7 @@ class FolderContents extends _$FolderContents { } else { _defaultSort = sortField; } - state = [...sort(state, _defaultSort)]; + state = sort(List.from(state), _defaultSort); } void watchFolder(Directory path) async { @@ -86,6 +90,10 @@ class FolderContents extends _$FolderContents { if (!state.contains(foi)) { if (!foi.isHidden) { add(foi); + // If the selectedFolderContentsProvider contains this folder, we should update the previewGridProvider manually. + if (ref.read(selectedFolderContentsProvider).contains(FileOfInterest(entity: Directory(path.path)))) { + ref.read(gridContentsProvider.notifier).add(foi); + } } } break; diff --git a/lib/providers/contents/grid_contents.dart b/lib/providers/contents/grid_contents.dart index e4a1b23..6e07f8e 100644 --- a/lib/providers/contents/grid_contents.dart +++ b/lib/providers/contents/grid_contents.dart @@ -1,57 +1,56 @@ import 'dart:io'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shackleton/providers/selected_entities/selected_folder_contents.dart'; -import '../../interfaces/file_events_callback.dart'; -import '../../models/file_of_interest.dart'; -import '../../providers/file_events.dart'; +import '../../../interfaces/file_events_callback.dart'; +import '../../../models/file_of_interest.dart'; +import '../../../providers/file_events.dart'; +import 'selected_folder_contents.dart'; part 'grid_contents.g.dart'; @Riverpod(keepAlive: true) class GridContents extends _$GridContents implements FileEventsCallback { @override - Set build() { + List build() { Future(() { register(); }); Set entities = ref.watch(selectedFolderContentsProvider); - if (entities.isEmpty) { - Set gridEntities = {}; - - for (var entity in entities) { - if (entity.canPreview) { - gridEntities.add(entity); - } else if (entity.isDirectory) { - Directory d = Directory(entity.path); - for (var e in d.listSync()) { - FileOfInterest foi = FileOfInterest(entity: e); - if (foi.canPreview) { - gridEntities.add(foi); - } + List gridEntities = []; + + for (var entity in entities) { + if (entity.canPreview) { + gridEntities.add(entity); + } else if (entity.isDirectory) { + Directory d = Directory(entity.path); + for (var e in d.listSync()) { + FileOfInterest foi = FileOfInterest(entity: e); + if (foi.canPreview) { + gridEntities.add(foi); } } } - return gridEntities; - } else { - return Set.from(entities); } + gridEntities.sort(); + return gridEntities; } void add(FileOfInterest entity) { if (!state.contains(entity)) { - state = { ...state, entity}; + state = [ ...state, entity ]; + state.sort(); } } void addAll(Set entities) { - state = { ...state, ...entities }; + state = { ...state, ...entities }.toList(); + state.sort(); } void clear() { - state = {}; + state = []; } bool contains(FileOfInterest entity) { @@ -68,33 +67,25 @@ class GridContents extends _$GridContents implements FileEventsCallback { @override void remove(FileOfInterest entity) { - if (entity.isDirectory) { - state = { + if (state.contains(entity)) { + state = [ for (var e in state) - if (!e.path.startsWith(entity.path)) - e - }; - } else { - if (state.contains(entity)) { - state = { - for (var e in state) - if (e.path != entity.path) - e - }; - } + if (e.path != entity.path) e + ]; } } void removeAll() { - state = {}; + state = []; } void replace(FileOfInterest entity) { - state = { entity }; + state = [ entity ]; } void replaceAll(Set entities) { - state = { ...entities }; + state = List.from(entities); + state.sort(); } int size() { diff --git a/lib/providers/contents/grid_tags.dart b/lib/providers/contents/grid_tags.dart index f4aa519..ed7c0dd 100644 --- a/lib/providers/contents/grid_tags.dart +++ b/lib/providers/contents/grid_tags.dart @@ -1,18 +1,24 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shackleton/providers/selected_entities/selected_grid_entities.dart'; import '../../models/file_metadata.dart'; import '../../models/file_of_interest.dart'; -import '../../providers/metadata.dart'; import '../../models/tag.dart'; +import '../../providers/metadata.dart'; + +import 'grid_contents.dart'; +import 'selected_grid_entities.dart'; -part 'selected_tags.g.dart'; +part 'grid_tags.g.dart'; @riverpod -class SelectedTags extends _$SelectedTags { +class GridTags extends _$GridTags { @override List build() { - Set entities = ref.watch(selectedEntitiesProvider); + List selectedEntities = ref.watch(selectedGridEntitiesProvider); + List gridEntities = ref.watch(gridContentsProvider); + + List entities = selectedEntities.isNotEmpty ? selectedEntities : gridEntities; + List metadata = entities.map((e) => ref.watch(metadataProvider(e))).toList(); List tags = [...{ for (var e in metadata) ...e.tags}]; tags.sort(); diff --git a/lib/providers/contents/pane_contents.dart b/lib/providers/contents/pane_contents.dart index b431cfa..583d1ab 100644 --- a/lib/providers/contents/pane_contents.dart +++ b/lib/providers/contents/pane_contents.dart @@ -1,29 +1,20 @@ -import 'dart:io'; - import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shackleton/providers/grid_contents.dart'; -import 'package:shackleton/providers/selected_entities/selected_folder_contents.dart'; -import 'package:shackleton/providers/selected_entities/selected_grid_entities.dart'; -import '../../interfaces/file_events_callback.dart'; -import '../../models/file_of_interest.dart'; -import '../../providers/file_events.dart'; +import '../../../interfaces/file_events_callback.dart'; +import '../../../models/file_of_interest.dart'; +import 'grid_contents.dart'; +import 'selected_grid_entities.dart'; part 'pane_contents.g.dart'; -@riverpod +@Riverpod(keepAlive: true) class PaneContents extends _$PaneContents implements FileEventsCallback { @override List build() { - Future(() { - register(); - }); + List selectedEntities = ref.watch(selectedGridEntitiesProvider); + List gridEntities = ref.watch(gridContentsProvider); + List sortedEntities = selectedEntities.isNotEmpty ? selectedEntities : gridEntities; - Set entities = ref.watch(selectedGridEntitiesProvider); - if (entities.isEmpty) { - entities = ref.watch(gridContentsProvider); - } - List sortedEntities = List.from(entities); sortedEntities.sort(); return sortedEntities; @@ -41,13 +32,15 @@ class PaneContents extends _$PaneContents implements FileEventsCallback { return state.contains(entity); } - Future register() async { - ref.read(fileEventsProvider.notifier).register(this); - } - @override void remove(FileOfInterest entity) { - state.remove(entity); + if (state.contains(entity)) { + state = [ + for (var e in state) + if (e.path != entity.path) + e + ]; + } } void removeAll() { diff --git a/lib/providers/contents/pane_tags.dart b/lib/providers/contents/pane_tags.dart index e69de29..47abbf2 100644 --- a/lib/providers/contents/pane_tags.dart +++ b/lib/providers/contents/pane_tags.dart @@ -0,0 +1,22 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../models/file_metadata.dart'; +import '../../models/file_of_interest.dart'; +import '../../models/tag.dart'; +import '../../providers/metadata.dart'; + +part 'pane_tags.g.dart'; + +@Riverpod(keepAlive: true) +class PaneTags extends _$PaneTags { + @override + List build() { + return []; + } + + void replace(FileOfInterest entity) { + FileMetaData metadata = ref.read(metadataProvider(entity)); + metadata.tags.sort(); + state = metadata.tags; + } +} \ No newline at end of file diff --git a/lib/providers/contents/selected_grid_entities.dart b/lib/providers/contents/selected_grid_entities.dart index ffb5f3d..e6f5737 100644 --- a/lib/providers/contents/selected_grid_entities.dart +++ b/lib/providers/contents/selected_grid_entities.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../interfaces/file_events_callback.dart'; @@ -11,26 +9,33 @@ part 'selected_grid_entities.g.dart'; @Riverpod(keepAlive: true) class SelectedGridEntities extends _$SelectedGridEntities implements FileEventsCallback { @override - Set build() { + List build() { Future(() { register(); }); - return {}; + return []; } void add(FileOfInterest entity) { if (!state.contains(entity)) { - state = { ...state, entity}; + state.add(entity); + state.sort(); + + state = List.from(state); } } void addAll(Set entities) { - state = { ...state, ...entities }; + Set entitySet = { ...state, ...entities }; + List newState = List.from(entitySet); + newState.sort(); + + state = newState; } void clear() { - state = {}; + state = []; } bool contains(FileOfInterest entity) { @@ -47,33 +52,28 @@ class SelectedGridEntities extends _$SelectedGridEntities implements FileEventsC @override void remove(FileOfInterest entity) { - if (entity.isDirectory) { - state = { + if (state.contains(entity)) { + state = [ for (var e in state) - if (!e.path.startsWith(entity.path)) + if (e.path != entity.path) e - }; - } else { - if (state.contains(entity)) { - state = { - for (var e in state) - if (e.path != entity.path) - e - }; - } + ]; } } void removeAll() { - state = {}; + state = []; } void replace(FileOfInterest entity) { - state = { entity }; + state = [ entity ]; } void replaceAll(Set entities) { - state = { ...entities }; + List newState = List.from(entities); + newState.sort(); + + state = newState; } int size() { diff --git a/lib/providers/contents/selected_grid_metadata.dart b/lib/providers/contents/selected_grid_metadata.dart index 8afb4d1..315583f 100644 --- a/lib/providers/contents/selected_grid_metadata.dart +++ b/lib/providers/contents/selected_grid_metadata.dart @@ -6,10 +6,10 @@ import '../../models/file_of_interest.dart'; import '../metadata.dart'; import 'selected_grid_entities.dart'; -part 'selected_metadata.g.dart'; +part 'selected_grid_metadata.g.dart'; @riverpod -class SelectedMetadata extends _$SelectedMetadata { +class SelectedGridMetadata extends _$SelectedGridMetadata { @override List build() { List selectedEntities = ref.watch(selectedGridEntitiesProvider); diff --git a/lib/providers/contents/selected_pane_entity.dart b/lib/providers/contents/selected_pane_entity.dart index e69de29..c9975ff 100644 --- a/lib/providers/contents/selected_pane_entity.dart +++ b/lib/providers/contents/selected_pane_entity.dart @@ -0,0 +1,17 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../models/file_of_interest.dart'; + +part 'selected_pane_entity.g.dart'; + +@riverpod +class SelectedPaneEntity extends _$SelectedPaneEntity { + @override + FileOfInterest? build() { + return null; + } + + void replace(FileOfInterest entity) { + state = entity; + } +} \ No newline at end of file diff --git a/lib/providers/file_events.dart b/lib/providers/file_events.dart index 6b9563e..599c881 100644 --- a/lib/providers/file_events.dart +++ b/lib/providers/file_events.dart @@ -40,6 +40,8 @@ class FileEvents extends _$FileEvents { } void register(FileEventsCallback callback) { - state = [...state, callback]; + if (!state.contains(callback)) { + state = [...state, callback]; + } } } diff --git a/lib/providers/location_update.dart b/lib/providers/location_update.dart index b549017..487f59f 100644 --- a/lib/providers/location_update.dart +++ b/lib/providers/location_update.dart @@ -13,6 +13,7 @@ class LocationUpdate extends _$LocationUpdate { void reset() { state = const LatLng(0, 0); } + void setLocation(LatLng location) { state = location; } diff --git a/lib/providers/photo_location.dart b/lib/providers/photo_location.dart index 4a46d20..5582cb3 100644 --- a/lib/providers/photo_location.dart +++ b/lib/providers/photo_location.dart @@ -4,7 +4,7 @@ import 'package:latlong2/latlong.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../models/file_metadata.dart'; -import 'selected_entities/selected_entities.dart'; +import 'contents/selected_grid_entities.dart'; import 'metadata.dart'; @@ -14,7 +14,7 @@ part 'photo_location.g.dart'; class PhotoLocation extends _$PhotoLocation { @override Future> build() async { - var selectedEntities = ref.watch(selectedEntitiesProvider(FileType.previewPane)); + var selectedEntities = ref.watch(selectedGridEntitiesProvider); List markers = []; for (var e in selectedEntities) { diff --git a/lib/widgets/entity_context_menu.dart b/lib/widgets/entity_context_menu.dart index 2618f67..c765c41 100644 --- a/lib/widgets/entity_context_menu.dart +++ b/lib/widgets/entity_context_menu.dart @@ -6,39 +6,38 @@ import '../misc/utils.dart'; import '../models/file_of_interest.dart'; import '../providers/file_events.dart'; import '../providers/folder_path.dart'; -import '../providers/selected_entities/selected_entities.dart'; +import '../providers/contents/selected_grid_entities.dart'; class EntityContextMenu extends ConsumerWidget { final Widget child; - final FileType fileType; final FileOfInterest? folder; - const EntityContextMenu({Key? key, required this.child, required this.fileType, this.folder}) : super(key: key); + const EntityContextMenu({super.key, required this.child, this.folder}); @override Widget build(BuildContext context, WidgetRef ref) { return ContextMenuWidget( child: child, menuProvider: (_) { - var selectedPreviewEntities = ref.read(selectedEntitiesProvider(fileType).notifier); - var entities = ref.watch(selectedEntitiesProvider(fileType)); + var selectedGridEntities = ref.read(selectedGridEntitiesProvider.notifier); + var entities = ref.watch(selectedGridEntitiesProvider); return Menu(children: [ MenuAction( - callback: () => selectedPreviewEntities.clear(), + callback: () => selectedGridEntities.clear(), image: MenuImage.icon(Icons.deselect), title: 'Deselect all', ), MenuAction( - callback: () => createZip(folder ?? FileOfInterest(entity: ref.read(folderPathProvider).first), entities), + callback: () => createZip(folder ?? FileOfInterest(entity: ref.read(folderPathProvider).first), entities.toSet()), image: MenuImage.icon(Icons.archive_outlined), title: 'Create (Zip) Archive', ), - if (ref.watch(selectedEntitiesProvider(fileType)).isNotEmpty) ...[ + if (entities.isNotEmpty) ...[ MenuSeparator(), MenuAction( attributes: const MenuActionAttributes(destructive: true), image: MenuImage.icon(Icons.delete), - callback: () => ref.read(fileEventsProvider.notifier).deleteAll(entities), + callback: () => ref.read(fileEventsProvider.notifier).deleteAll(entities.toSet()), title: 'Delete selected files', ), ] diff --git a/lib/widgets/entity_preview.dart b/lib/widgets/entity_preview.dart index 517b3ec..e2565ef 100644 --- a/lib/widgets/entity_preview.dart +++ b/lib/widgets/entity_preview.dart @@ -13,8 +13,9 @@ class EntityPreview extends ConsumerStatefulWidget { final FileOfInterest entity; final bool isSelected; final bool displayMetadata; + final double previewWidth; - const EntityPreview({Key? key, required this.entity, required this.isSelected, this.displayMetadata = true}) : super(key: key); + const EntityPreview({super.key, required this.entity, required this.isSelected, required this.previewWidth, this.displayMetadata = true}); @override ConsumerState createState() => _EntityPreview(); @@ -26,6 +27,7 @@ class _EntityPreview extends ConsumerState { get displayMetaData => widget.displayMetadata; get selectedEntity => widget.entity; get isSelected => widget.isSelected; + get previewWidth => widget.previewWidth; get background => isSelected ? Theme.of(context).textSelectionTheme.selectionHandleColor! : Colors.transparent; @override @@ -141,7 +143,7 @@ class _EntityPreview extends ConsumerState { return VideoPreview(entity: selectedEntity, isSelected: isSelected); } - return ImagePreview(entity: selectedEntity, isSelected: isSelected); + return ImagePreview(entity: selectedEntity, isSelected: isSelected, previewWidth: previewWidth); } bool _replaceTags(WidgetRef ref, String tags) { diff --git a/lib/widgets/folder_list.dart b/lib/widgets/folder_list.dart index 60f319e..9d61616 100644 --- a/lib/widgets/folder_list.dart +++ b/lib/widgets/folder_list.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart'; import 'package:super_drag_and_drop/super_drag_and_drop.dart'; +import 'package:window_manager/window_manager.dart'; import '../interfaces/keyboard_callback.dart'; import '../misc/keyboard_handler.dart'; @@ -13,17 +14,17 @@ import '../misc/utils.dart'; import '../models/file_of_interest.dart'; import '../models/folder_ui_settings.dart'; import '../providers/file_events.dart'; -import '../providers/folder_contents.dart'; +import '../providers/contents/folder_contents.dart'; +import '../providers/contents/selected_folder_contents.dart'; import '../providers/folder_path.dart'; import '../providers/metadata.dart'; -import '../providers/selected_entities/selected_entities.dart'; import '../repositories/folder_settings_repository.dart'; import 'entity_context_menu.dart'; class FolderList extends ConsumerStatefulWidget { final Directory path; - const FolderList({Key? key, required this.path}) : super(key: key); + const FolderList({super.key, required this.path}); @override ConsumerState createState() => _FolderList(); @@ -96,14 +97,13 @@ class _FolderList extends ConsumerState implements KeyboardCallback ) : null, child: EntityContextMenu( - fileType: FileType.folderList, folder: FileOfInterest(entity: folderPath), child: Padding( padding: const EdgeInsets.only(top: 6, bottom: 6, right: 10), child: Column( children: [ _getFolderColumnHeaders(context, folderSettings), - Container(color: const Color.fromRGBO(217, 217, 217, 100), height: 2), + Container(color: const Color.fromRGBO(217, 217, 217, 100), height: 2, margin: const EdgeInsets.only(left: 8.0)), Expanded(child: _getListView(folderSettings)), _getFolderSettingsIcons(folderSettings), ], @@ -116,12 +116,23 @@ class _FolderList extends ConsumerState implements KeyboardCallback ), MouseRegion( cursor: SystemMouseCursors.resizeColumn, + key: const Key('resize'), child: GestureDetector( - onHorizontalDragUpdate: (DragUpdateDetails details) { + onHorizontalDragUpdate: (DragUpdateDetails details) async { var folderNotifier = ref.read(folderSettingsRepositoryProvider(folderPath.path).notifier); folderNotifier.updateSettings(folderSettings.copyWith(width: folderSettings.width + details.delta.dx)); + + // Resize the window if we are resizing the rightmost FolderList and it is butted up against the right hand side of the window. + if (mounted) { + Size windowSize = await windowManager.getSize(); + double widgetPosition = _getWidgetPosition(context)!.right; + + if (widgetPosition > windowSize.width - 10) { + windowManager.setSize(Size(windowSize.width + details.delta.dx, windowSize.height)); + } + } }, - child: Container(color: const Color.fromRGBO(217, 217, 217, 100), width: 3), + child: Container(color: const Color.fromRGBO(217, 217, 217, 100), width: 3, margin: const EdgeInsets.only(right: 6),), )), ]), ); @@ -143,36 +154,6 @@ class _FolderList extends ConsumerState implements KeyboardCallback handler.register(); } - void _addSelectedEntity(FileOfInterest entity) { - var folderListSelection = ref.read(selectedEntitiesProvider(FileType.folderList).notifier); - folderListSelection.add(entity); - - // We want to add selected entities to both the folder list selection, and the preview grid. - var previewGridSelection = ref.read(selectedEntitiesProvider(FileType.previewGrid).notifier); - if (entity.canPreview) { - previewGridSelection.add(entity); - } else if (entity.isDirectory) { - Set selectedFiles = {}; - - Directory d = Directory(entity.path); - for (var e in d.listSync()) { - FileOfInterest foi = FileOfInterest(entity: e); - if (foi.canPreview) { - selectedFiles.add(foi); - } - } - previewGridSelection.replaceAll(selectedFiles); - } - } - - _clearSelectedEntities() { - var folderListSelection = ref.read(selectedEntitiesProvider(FileType.folderList).notifier); - folderListSelection.clear(); - - var previewGridSelection = ref.read(selectedEntitiesProvider(FileType.previewGrid).notifier); - previewGridSelection.clear(); - } - Widget _getEntityRow(BuildContext context, FileOfInterest entity, bool showDetails) { TextEditingController tagController = TextEditingController(); tagController.text = entity.name; @@ -222,7 +203,7 @@ class _FolderList extends ConsumerState implements KeyboardCallback formats: Formats.standardFormats, hitTestBehavior: HitTestBehavior.opaque, onDropOver: (event) { - _selectIfValidDropLocation(event, entity); + _selectIfValidDropLocation(context, event, entity); return _onDropOver(event); }, onDropEnter: (event) {}, @@ -314,12 +295,12 @@ class _FolderList extends ConsumerState implements KeyboardCallback } Widget _getListView(FolderUISettings settings) { - Set selectedEntities = ref.watch(selectedEntitiesProvider(FileType.folderList)); + Set selectedEntities = ref.watch(selectedFolderContentsProvider); List entityList = List.from(entities); entityList.removeWhere((element) => !settings.showHiddenFiles && element.isHidden == true); - entityList.sort(); List keys = List.filled(entityList.length, null, growable: false); + return SingleChildScrollView( child: ListView.builder( itemCount: entityList.length, @@ -327,7 +308,7 @@ class _FolderList extends ConsumerState implements KeyboardCallback FileOfInterest entity = entityList[index]; keys[index] = GlobalKey(); return InkWell( - onTap: () => _selectEntry(entityList, index), + onTap: () => _selectEntry(context, entityList, index), onDoubleTap: () => entity.openFile(), child: DragItemWidget( key: keys[index], @@ -368,7 +349,19 @@ class _FolderList extends ConsumerState implements KeyboardCallback ); } - void _selectIfValidDropLocation(DropOverEvent event, FileOfInterest destination) { + Rect? _getWidgetPosition(BuildContext context) { + final renderObject = context.findRenderObject(); + final matrix = renderObject?.getTransformTo(null); + + if (matrix != null && renderObject?.paintBounds != null) { + final rect = MatrixUtils.transformRect(matrix, renderObject!.paintBounds); + return rect; + } else { + return null; + } + } + + void _selectIfValidDropLocation(BuildContext context, DropOverEvent event, FileOfInterest destination) { final item = event.session.items.first; final reader = item.dataReader!; if (item.canProvide(Formats.fileUri)) { @@ -377,11 +370,11 @@ class _FolderList extends ConsumerState implements KeyboardCallback if (destination.isDirectory) { FileOfInterest source = FileOfInterest(entity: Directory.fromUri(uri)); if (source.isValidMoveLocation(destination.path)) { - _selectEntry(entities, entities.indexOf(destination), shouldEditName: false); + _selectEntry(context, entities, entities.indexOf(destination), shouldEditName: false); return; } } - ref.read(selectedEntitiesProvider(FileType.folderList).notifier).removeAll(); + ref.read(selectedFolderContentsProvider.notifier).removeAll(); } }); } @@ -425,7 +418,7 @@ class _FolderList extends ConsumerState implements KeyboardCallback entity.rename(filename); } - void _selectEntry(List entities, int index, {bool shouldEditName = true}) { + void _selectEntry(BuildContext context, List entities, int index, {bool shouldEditName = true}) { FileOfInterest entity = entities[index]; // Cancel editing in the PreviewGrid if we are making selections. @@ -436,8 +429,9 @@ class _FolderList extends ConsumerState implements KeyboardCallback ref.read(folderPathProvider.notifier).addFolder(widget.path, entity.entity as Directory); } + var selectedFolderContents = ref.read(selectedFolderContentsProvider.notifier); if (handler.isIndividualMultiSelectionPressed) { - _toggleSelectedEntity(entity); + selectedFolderContents.contains(entity) ? selectedFolderContents.remove(entity) : selectedFolderContents.add(entity); } else if (handler.isBlockMultiSelectionPressed) { } else if (handler.isBlockMultiSelectionPressed) { if (_lastSelectedItemIndex != -1) { int start = _lastSelectedItemIndex; @@ -450,7 +444,7 @@ class _FolderList extends ConsumerState implements KeyboardCallback } for (int i = start; i <= end; i++) { - _addSelectedEntity(entities[i]); + selectedFolderContents.add(entities[i]); } } } else { @@ -463,38 +457,27 @@ class _FolderList extends ConsumerState implements KeyboardCallback handler.setEditing(true); } } else { - _toggleSelectedEntity(entity, reset: false); + selectedFolderContents.replace(entity); } } else { _lastSelectedItemIndex = index; - - _toggleSelectedEntity(entity, reset: true); + selectedFolderContents.replace(entity); } _lastSelectedTimestamp = currentTimestamp; } - } - - void _toggleSelectedEntity(FileOfInterest entity, {bool reset = false}) { - var folderListSelection = ref.read(selectedEntitiesProvider(FileType.folderList).notifier); - var previewGridSelection = ref.read(selectedEntitiesProvider(FileType.previewGrid).notifier); - - if (reset) { - folderListSelection.clear(); - previewGridSelection.clear(); - } - if (folderListSelection.contains(entity)) { - folderListSelection.remove(entity); - previewGridSelection.remove(entity); - } else { - _addSelectedEntity(entity); - } + Scrollable.ensureVisible(context); } @override void delete() { var fileEvents = ref.read(fileEventsProvider.notifier); - fileEvents.deleteAll(ref.watch(selectedEntitiesProvider(FileType.folderList))); + fileEvents.deleteAll(ref.watch(selectedFolderContentsProvider)); + } + + @override + void down() { + } @override @@ -525,12 +508,15 @@ class _FolderList extends ConsumerState implements KeyboardCallback } + @override + void up() { + + } + @override void selectAll() { - var selectedEntities = ref.read(selectedEntitiesProvider(FileType.folderList).notifier); + var selectedEntities = ref.read(selectedFolderContentsProvider.notifier); selectedEntities.addAll(entities.toSet()); - var gridEntities = ref.read(selectedEntitiesProvider(FileType.previewGrid).notifier); - gridEntities.addAll(entities.toSet()); } } \ No newline at end of file diff --git a/lib/widgets/import_folder.dart b/lib/widgets/import_folder.dart index 72db666..8ad3ca2 100644 --- a/lib/widgets/import_folder.dart +++ b/lib/widgets/import_folder.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shackleton/providers/selected_entities/selected_entities.dart'; + import '../models/file_of_interest.dart'; import '../models/import_entity.dart'; +import '../providers/contents/selected_folder_contents.dart'; import '../providers/import.dart'; class ImportFolder extends ConsumerStatefulWidget { - const ImportFolder({Key? key,}) : super(key: key); + const ImportFolder({super.key,}); @override ConsumerState createState() => _ImportFolder(); @@ -18,7 +19,7 @@ class _ImportFolder extends ConsumerState { @override Widget build(BuildContext context) { - Set entities = ref.watch(selectedEntitiesProvider(FileType.folderList)); + Set entities = ref.watch(selectedFolderContentsProvider); return Scaffold( appBar: AppBar( diff --git a/lib/widgets/metadata/metadata_editor.dart b/lib/widgets/metadata/metadata_editor.dart index 229da11..fc5a187 100644 --- a/lib/widgets/metadata/metadata_editor.dart +++ b/lib/widgets/metadata/metadata_editor.dart @@ -1,81 +1,83 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../interfaces/keyboard_callback.dart'; +import '../../interfaces/tag_handler.dart'; import '../../misc/keyboard_handler.dart'; import '../../models/file_metadata.dart'; import '../../models/file_of_interest.dart'; import '../../models/tag.dart'; import '../../providers/metadata.dart'; -import '../../providers/selected_entities/selected_entities.dart'; -import '../../providers/selected_entities/selected_tags.dart'; +import '../../providers/contents/grid_tags.dart'; +import '../../providers/contents/grid_contents.dart'; +import '../../providers/contents/pane_tags.dart'; import 'metadata_location.dart'; class MetadataEditor extends ConsumerStatefulWidget { - final FileType completeListType; - final FileType selectedListType; - final KeyboardCallback callback; + final KeyboardCallback keyHandlerCallback; + final FileOfInterest? paneEntity; + final TagHandler tagHandler; - const MetadataEditor({Key? key, required this.completeListType, required this.selectedListType, required this.callback}) : super(key: key); + const MetadataEditor({super.key, required this.keyHandlerCallback, required this.tagHandler, this.paneEntity, }); @override ConsumerState createState() => _MetadataEditor(); } class _MetadataEditor extends ConsumerState implements KeyboardCallback { - late KeyboardHandler handler; late TextEditingController tagController; + late KeyboardHandler handler; late FocusNode focusNode; Timer? _debounce; - get callback => widget.callback; - get completeListType => widget.completeListType; - get selectedListType => widget.selectedListType; + get keyHandlerCallback => widget.keyHandlerCallback; + get tagHandler => widget.tagHandler; + get paneEntity => widget.paneEntity; @override Widget build(BuildContext context,) { - final List tags = ref.watch(selectedTagsProvider(selectedListType, completeListType)); + final List tags = paneEntity == null ? ref.watch(gridTagsProvider) : ref.watch(paneTagsProvider); - return MouseRegion( - onEnter: (_) => handler.hasFocus = true, - onExit: (_) => handler.hasFocus = false, - child: Padding( + return Padding( padding: const EdgeInsets.only(top: 6, bottom: 6, right: 10), child: Column( children: [ Text('Metadata', style: Theme.of(context).textTheme.labelSmall,), const SizedBox(height: 10), Expanded( - child: ListView.builder( - itemCount: tags.length, - itemBuilder: (context, index) { - return Container( - color: index % 2 == 1 ? Theme.of(context).textSelectionTheme.selectionHandleColor! : Colors.white, - padding: const EdgeInsets.only(bottom: 2), - child: Row(children: [ - Expanded( - child: GestureDetector( - onTap: () => _filterByTag(ref, tags[index]), - child: Text(tags[index].tag, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall), - ), - ), - const SizedBox(width: 5), - IconButton( - icon: const Icon(Icons.clear), - constraints: const BoxConstraints(minHeight: 12, maxHeight: 12), - iconSize: 12, - padding: EdgeInsets.zero, - splashRadius: 0.0001, - tooltip: 'Remove tag from selected images...', - onPressed: () => _removeTags(ref, tags, index)), - ]), - ); - })), - const Spacer(), - MetadataLocation(selectedListType: selectedListType, completeListType: completeListType,), - const SizedBox(height: 30), + child: tags.isNotEmpty + ? ListView.builder( + itemCount: tags.length, + itemBuilder: (context, index) { + return Container( + color: index % 2 == 1 ? Theme.of(context).textSelectionTheme.selectionHandleColor! : Colors.white, + padding: const EdgeInsets.only(bottom: 2), + child: Row(children: [ + Expanded( + child: GestureDetector( + onTap: () => _filterByTag(ref, tags[index]), + child: Text(tags[index].tag, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall), + ), + ), + const SizedBox(width: 5), + IconButton( + icon: const Icon(Icons.clear), + constraints: const BoxConstraints(minHeight: 12, maxHeight: 12), + iconSize: 12, + padding: EdgeInsets.zero, + splashRadius: 0.0001, + tooltip: 'Remove tag from selected images...', + onPressed: () => _removeTag(ref, tags[index])), + ]), + ); + }) + : Center(child: Text('No tags for selected image(s)', style: Theme.of(context).textTheme.bodySmall))), + const SizedBox(height: 10), + MetadataLocation(paneEntity: widget.paneEntity), + const SizedBox(height: 20), Row( children: [ Expanded( @@ -86,6 +88,10 @@ class _MetadataEditor extends ConsumerState implements KeyboardC focusNode: focusNode, keyboardType: TextInputType.text, maxLines: 1, + onChanged: (text) { + handler.hasFocus = text.isEmpty; + debugPrint('changing focus to: ${handler.hasFocus}'); + }, onSubmitted: (tags) => _updateTags(ref, tags), style: Theme.of(context).textTheme.bodySmall, ), @@ -102,7 +108,6 @@ class _MetadataEditor extends ConsumerState implements KeyboardC ), ], ), - ), ); } @@ -121,13 +126,14 @@ class _MetadataEditor extends ConsumerState implements KeyboardC focusNode = FocusNode(); handler = KeyboardHandler(ref: ref, keyboardCallback: this); + handler.processModifierKeys = false; handler.register(); } void _filterByTag(WidgetRef ref, Tag tag) { Set filteredEntities = {}; - Set gridEntries = ref.watch(selectedEntitiesProvider(completeListType)); + List gridEntries = ref.watch(gridContentsProvider); for (var e in gridEntries) { FileMetaData meta = ref.watch(metadataProvider(e)); if (meta.contains(tag)) { @@ -135,33 +141,18 @@ class _MetadataEditor extends ConsumerState implements KeyboardC } } - var selectedList = ref.read(selectedEntitiesProvider(selectedListType).notifier); - selectedList.replaceAll(filteredEntities); - } - - Set _getSelectedEntities() { - Set entities = ref.read(selectedEntitiesProvider(selectedListType)); - if (entities.isEmpty) { - entities = ref.read(selectedEntitiesProvider(completeListType)); + if (!const DeepCollectionEquality.unordered().equals(gridEntries, filteredEntities)) { + var selectedList = ref.read(gridContentsProvider.notifier); + selectedList.replaceAll(filteredEntities); } - - return entities; } - void _removeTags(WidgetRef ref, List tags, int index) { - Set entities = _getSelectedEntities(); - - for (var e in entities) { - ref.read(metadataProvider(e).notifier).removeTags(tags[index]); - } + void _removeTag(WidgetRef ref, Tag tag) { + tagHandler.removeTag(tag); } bool _updateTags(WidgetRef ref, String tags) { - Set entities = _getSelectedEntities(); - - for (var e in entities) { - ref.read(metadataProvider(e).notifier).updateTagsFromString(tags); - } + tagHandler.updateTags(tags); tagController.text = ''; focusNode.requestFocus(); @@ -183,22 +174,32 @@ class _MetadataEditor extends ConsumerState implements KeyboardC if (_debounce?.isActive ?? false) { return; } else { - if (tagController.text.isEmpty) callback.delete(); + if (tagController.text.isEmpty) keyHandlerCallback.delete(); } } @override void left() { - if (tagController.text.isEmpty) callback.left(); + if (tagController.text.isEmpty) keyHandlerCallback.left(); } @override void right() { - if (tagController.text.isEmpty) callback.right(); + if (tagController.text.isEmpty) keyHandlerCallback.right(); + } + + @override + void up() { + if (tagController.text.isEmpty) keyHandlerCallback.up(); + } + + @override + void down() { + if (tagController.text.isEmpty) keyHandlerCallback.down(); } @override - void exit() => callback.exit(); + void exit() => keyHandlerCallback.exit(); @override void newEntity() {} diff --git a/lib/widgets/metadata/metadata_location.dart b/lib/widgets/metadata/metadata_location.dart index 4656b12..5f81001 100644 --- a/lib/widgets/metadata/metadata_location.dart +++ b/lib/widgets/metadata/metadata_location.dart @@ -5,21 +5,20 @@ import 'package:latlong2/latlong.dart'; import '../../misc/utils.dart'; import '../../models/file_metadata.dart'; +import '../../models/file_of_interest.dart'; import '../../providers/location_update.dart'; import '../../providers/metadata.dart'; -import '../../providers/selected_entities/selected_entities.dart'; -import '../../providers/selected_entities/selected_metadata.dart'; +import '../../providers/contents/selected_grid_metadata.dart'; class MetadataLocation extends ConsumerWidget { - final FileType selectedListType; - final FileType completeListType; + final FileOfInterest? paneEntity; - const MetadataLocation({Key? key, required this.selectedListType, required this.completeListType}) : super(key: key); + const MetadataLocation({super.key, this.paneEntity, }); @override Widget build(BuildContext context, WidgetRef ref,) { LatLng newLocation = ref.watch(locationUpdateProvider); - List metadata = ref.watch(selectedMetadataProvider(selectedListType, completeListType)); + List metadata = paneEntity != null ? [ ref.watch(metadataProvider(paneEntity!))] : ref.watch(selectedGridMetadataProvider); var map = {}; for (var m in metadata) { diff --git a/lib/widgets/navigation.dart b/lib/widgets/navigation.dart index 78d3d1e..1541d2a 100644 --- a/lib/widgets/navigation.dart +++ b/lib/widgets/navigation.dart @@ -5,7 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../misc/utils.dart'; import '../models/file_of_interest.dart'; -import '../providers/selected_entities/selected_entities.dart'; +import '../models/folder_ui_settings.dart'; +import '../repositories/folder_settings_repository.dart'; import 'entity_context_menu.dart'; import 'navigation/navigation_favourites.dart'; import 'navigation/navigation_mounted_drives.dart'; @@ -13,7 +14,7 @@ import 'navigation/navigation_space.dart'; import 'navigation/navigation_tags.dart'; class Navigation extends ConsumerStatefulWidget { - const Navigation({Key? key,}) : super(key: key); + const Navigation({super.key,}); @override ConsumerState createState() => _Navigation(); @@ -22,64 +23,70 @@ class Navigation extends ConsumerStatefulWidget { class _Navigation extends ConsumerState { final linuxMountFolder = Directory('/media'); final macosMountFolder = Directory('/Volumes'); - double _width = 250; bool mouseHover = false; @override Widget build(BuildContext context) { ScrollController controller = ScrollController(); - return Row(children: [ - SizedBox( - width: _width, - child: MouseRegion( - onEnter: (_) { - setState(() { - mouseHover = true; - }); - }, - onExit: (_) { - mouseHover = false; - }, - child: Container( - alignment: Alignment.topLeft, - color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.4), - child: EntityContextMenu( - fileType: FileType.folderList, - folder: FileOfInterest(entity: Directory(getHomeFolder())), - child: Padding( - padding: const EdgeInsets.only(top: 6, bottom: 6, right: 10), - child: SingleChildScrollView( - controller: controller, - child: Column( - children: [ - const NavigationSpace(), - Container(color: Theme.of(context).primaryColorLight, height: 2), - const NavigationFavourites(), - if (Platform.isMacOS || Platform.isLinux) ...[ - const SizedBox(height: 10), - NavigationMountedDrives(mountPoint: Platform.isMacOS ? macosMountFolder : linuxMountFolder), - ], - const SizedBox(height: 10), - const NavigationTags(), - ], + return Consumer(builder: (context, watch, child) { + var folderSettings = ref.watch(folderSettingsRepositoryProvider(navigationFolder)); + return folderSettings.when(error: (error, stackTrace) { + return Text('Failed to get settings', style: Theme.of(context).textTheme.bodySmall); + }, loading: () { + return const CircularProgressIndicator(); + }, data: (FolderUISettings folderSettings) { + return Row(children: [ + SizedBox( + width: folderSettings.width, + child: MouseRegion( + onEnter: (_) { + setState(() { + mouseHover = true; + }); + }, + onExit: (_) { + mouseHover = false; + }, + child: Container( + alignment: Alignment.topLeft, + color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.4), + child: EntityContextMenu( + folder: FileOfInterest(entity: Directory(getHomeFolder())), + child: Padding( + padding: const EdgeInsets.only(top: 6, bottom: 6, right: 10), + child: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + const NavigationSpace(), + Container(color: Theme.of(context).primaryColorLight, height: 2), + const NavigationFavourites(), + if (Platform.isMacOS || Platform.isLinux) ...[ + const SizedBox(height: 10), + NavigationMountedDrives(mountPoint: Platform.isMacOS ? macosMountFolder : linuxMountFolder), + ], + const SizedBox(height: 10), + const NavigationTags(), + ], + ), + ), ), ), ), ), ), - ), - ), - MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - child: GestureDetector( - onHorizontalDragUpdate: (DragUpdateDetails details) { - setState(() { - _width += details.delta.dx; - }); - }, - child: Container(color: const Color.fromRGBO(217, 217, 217, 100), width: 3), - )), - ]); + MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + onHorizontalDragUpdate: (DragUpdateDetails details) { + var folderNotifier = ref.read(folderSettingsRepositoryProvider(navigationFolder).notifier); + folderNotifier.updateSettings(folderSettings.copyWith(width: folderSettings.width + details.delta.dx)); + }, + child: Container(color: const Color.fromRGBO(217, 217, 217, 100), width: 3), + )), + ]); + }); + }); } } \ No newline at end of file diff --git a/lib/widgets/navigation/navigation_favourites.dart b/lib/widgets/navigation/navigation_favourites.dart index bcaded7..f20118a 100644 --- a/lib/widgets/navigation/navigation_favourites.dart +++ b/lib/widgets/navigation/navigation_favourites.dart @@ -8,7 +8,7 @@ import '../../models/favourite.dart'; import '../../repositories/favourites_repository.dart'; class NavigationFavourites extends ConsumerStatefulWidget { - const NavigationFavourites({Key? key,}) : super(key: key); + const NavigationFavourites({super.key,}); @override ConsumerState createState() => _NavigationFavourites(); diff --git a/lib/widgets/navigation/navigation_mounted_drives.dart b/lib/widgets/navigation/navigation_mounted_drives.dart index 3a78495..063551f 100644 --- a/lib/widgets/navigation/navigation_mounted_drives.dart +++ b/lib/widgets/navigation/navigation_mounted_drives.dart @@ -4,12 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:process_run/cmd_run.dart'; -import '../../providers/folder_contents.dart'; +import '../../providers/contents/folder_contents.dart'; import '../../providers/folder_path.dart'; class NavigationMountedDrives extends ConsumerStatefulWidget { final Directory mountPoint; - const NavigationMountedDrives({Key? key, required this.mountPoint}) : super(key: key); + const NavigationMountedDrives({super.key, required this.mountPoint}); @override ConsumerState createState() => _NavigationMountedDrives(); diff --git a/lib/widgets/navigation/navigation_space.dart b/lib/widgets/navigation/navigation_space.dart index 43af39d..40d47c3 100644 --- a/lib/widgets/navigation/navigation_space.dart +++ b/lib/widgets/navigation/navigation_space.dart @@ -7,7 +7,7 @@ import '../../misc/utils.dart'; import '../../providers/disk_size_details.dart'; class NavigationSpace extends ConsumerWidget { - const NavigationSpace({Key? key,}) : super(key: key); + const NavigationSpace({super.key,}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/navigation/navigation_tags.dart b/lib/widgets/navigation/navigation_tags.dart index 6b5d186..f9ea5bb 100644 --- a/lib/widgets/navigation/navigation_tags.dart +++ b/lib/widgets/navigation/navigation_tags.dart @@ -8,11 +8,11 @@ import '../../misc/utils.dart'; import '../../models/entity.dart'; import '../../models/file_of_interest.dart'; import '../../models/tag.dart'; -import '../../providers/selected_entities/selected_entities.dart'; +import '../../providers/contents/grid_contents.dart'; import '../../repositories/file_tags_repository.dart'; class NavigationTags extends ConsumerWidget { - const NavigationTags({Key? key,}) : super(key: key); + const NavigationTags({super.key,}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -58,6 +58,6 @@ class NavigationTags extends ConsumerWidget { tagSet.add(FileOfInterest(entity: File(e.path))); } } - ref.read(selectedEntitiesProvider(FileType.previewGrid).notifier).replaceAll(tagSet); + ref.read(gridContentsProvider.notifier).replaceAll(tagSet); } } \ No newline at end of file diff --git a/lib/widgets/preview/image_preview.dart b/lib/widgets/preview/image_preview.dart index 658c9dd..f34a8cc 100644 --- a/lib/widgets/preview/image_preview.dart +++ b/lib/widgets/preview/image_preview.dart @@ -11,8 +11,9 @@ import '../../providers/metadata.dart'; class ImagePreview extends ConsumerStatefulWidget { final FileOfInterest entity; final bool isSelected; + final double previewWidth; - const ImagePreview({Key? key, required this.entity, required this.isSelected,}) : super(key: key); + const ImagePreview({super.key, required this.entity, required this.isSelected, required this.previewWidth}); @override ConsumerState createState() => _ImagePreview(); @@ -24,6 +25,7 @@ class _ImagePreview extends ConsumerState { get entityPreview => widget.entity; get isSelected => widget.isSelected; + get previewWidth => widget.previewWidth; @override Widget build(BuildContext context) { @@ -46,8 +48,17 @@ class _ImagePreview extends ConsumerState { color: isSelected ? Theme.of(context).textSelectionTheme.selectionHandleColor! : Colors.transparent, padding: const EdgeInsets.symmetric(vertical: 10), child: _rotatedBytes == null - ? Image.file(entityPreview.entity as File, alignment: Alignment.center, fit: BoxFit.contain) - : Image.memory(_rotatedBytes!, alignment: Alignment.center, fit: BoxFit.contain), + ? Image.file(entityPreview.entity as File, + alignment: Alignment.center, + fit: BoxFit.contain, + width: previewWidth, + cacheWidth: (previewWidth * MediaQuery.of(context).devicePixelRatio).round(),) + : Image.memory(_rotatedBytes!, + alignment: Alignment.center, + fit: BoxFit.contain, + width: previewWidth, + cacheWidth: (previewWidth * MediaQuery.of(context).devicePixelRatio).round(), + ), ), ), ], diff --git a/lib/widgets/preview/video_preview.dart b/lib/widgets/preview/video_preview.dart index b7366bb..649c5ba 100644 --- a/lib/widgets/preview/video_preview.dart +++ b/lib/widgets/preview/video_preview.dart @@ -63,6 +63,7 @@ class _VideoPreview extends ConsumerState { @override void initState() { super.initState(); + _player.open(Media(widget.entity.path), play: false); } } diff --git a/lib/widgets/preview_grid.dart b/lib/widgets/preview_grid.dart index 17775b2..a9d5280 100644 --- a/lib/widgets/preview_grid.dart +++ b/lib/widgets/preview_grid.dart @@ -3,14 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:super_drag_and_drop/super_drag_and_drop.dart'; import '../interfaces/keyboard_callback.dart'; +import '../interfaces/tag_handler.dart'; import '../misc/keyboard_handler.dart'; import '../models/file_of_interest.dart'; import '../models/map_settings.dart'; +import '../models/tag.dart'; import '../providers/file_events.dart'; import '../providers/map_pane.dart'; import '../providers/metadata.dart'; -import '../providers/selected_entities/selected_entities.dart'; -import '../providers/selected_entities/selected_previewable_entities.dart'; +import '../providers/contents/grid_contents.dart'; +import '../providers/contents/selected_grid_entities.dart'; import 'entity_preview.dart'; import 'entity_context_menu.dart'; import 'metadata/metadata_editor.dart'; @@ -18,24 +20,30 @@ import 'preview_pane.dart'; import 'preview/photo_map.dart'; class PreviewGrid extends ConsumerStatefulWidget { - const PreviewGrid({Key? key}) : super(key: key); + const PreviewGrid({super.key}); @override ConsumerState createState() => _PreviewGrid(); } -class _PreviewGrid extends ConsumerState implements KeyboardCallback { +class _PreviewGrid extends ConsumerState implements KeyboardCallback, TagHandler { late List entities; + late List selectedEntities; late KeyboardHandler handler; + ScrollController gridController = ScrollController(); + List keys = []; + double mapWidth = 0; + int gridColumns = 5; int _lastSelectedItemIndex = -1; - // TODO: Add key navigation - @override Widget build(BuildContext context) { MapSettings map = ref.watch(mapPaneProvider); - entities = ref.watch(selectedPreviewableEntitiesProvider(FileType.previewGrid)); + entities = ref.watch(gridContentsProvider); + selectedEntities = ref.watch(selectedGridEntitiesProvider); + + mapWidth = map.width; return entities.isEmpty ? Padding( @@ -47,7 +55,6 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac : Row(children: [ Expanded( child: EntityContextMenu( - fileType: FileType.previewPane, child: MouseRegion( onEnter: (_) => handler.hasFocus = true, onExit: (_) => handler.hasFocus = false, @@ -68,7 +75,7 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac SizedBox(width: map.width, child: const PhotoMap()), ], const VerticalDivider(), - SizedBox(width: 200, child: MetadataEditor(completeListType: FileType.previewGrid, selectedListType: FileType.previewPane, callback: this)), + SizedBox(width: 210, child: MetadataEditor(keyHandlerCallback: this, tagHandler: this)), ]); } @@ -86,13 +93,23 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac handler.register(); } + void _ensureSelectedItemVisible() { + int column = (_lastSelectedItemIndex / gridColumns).floor(); + GlobalKey? key = keys[_lastSelectedItemIndex]; + if (key != null) { + var columnHeight = key.currentState!.context.size!.height; + gridController.animateTo(columnHeight * column, duration: const Duration(milliseconds: 500), curve: Curves.decelerate); + } + } + Widget _getGridView() { - var selectedEntities = ref.watch(selectedEntitiesProvider(FileType.previewPane)); - List keys = List.filled(entities.length, null, growable: false); + keys = List.filled(entities.length, null, growable: false); return LayoutBuilder( builder: (context, constraints) { + gridColumns = switch (constraints.maxWidth) { < 1024 => 3, < 2048 => 5, _ => 7 }; return GridView.builder( + controller: gridController, itemCount: entities.length, itemBuilder: (context, index) { keys[index] = GlobalKey(); @@ -126,11 +143,15 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac } return dragItems; }, - child: EntityPreview(entity: entities[index], isSelected: selectedEntities.contains(entities[index]), displayMetadata: true,), + child: EntityPreview( + entity: entities[index], + isSelected: selectedEntities.contains(entities[index]), + displayMetadata: true, + previewWidth: (MediaQuery.of(context).size.width - 210 - mapWidth) / gridColumns), ))); }, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: switch (constraints.maxWidth) { < 1024 => 3, < 2048 => 5, _ => 7 }, + crossAxisCount: gridColumns, crossAxisSpacing: 10, mainAxisSpacing: 10, ), @@ -142,7 +163,6 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac } void _previewEntities(FileOfInterest tappedEntity) { - var selectedEntities = ref.read(selectedEntitiesProvider(FileType.previewPane).notifier); if (!selectedEntities.contains(tappedEntity)) { // If we double tap on an unselectedEntity, assume we want to browse everything in detail. selectAll(); @@ -157,9 +177,9 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac ref.read(metadataProvider(entity).notifier).setEditable(false); int index = entities.indexOf(entity); - var selectedEntities = ref.read(selectedEntitiesProvider(FileType.previewPane).notifier); + var entityNotifier = ref.read(selectedGridEntitiesProvider.notifier); if (handler.isIndividualMultiSelectionPressed) { - selectedEntities.contains(entity) ? selectedEntities.remove(entity) : selectedEntities.add(entity); + entityNotifier.contains(entity) ? entityNotifier.remove(entity) : entityNotifier.add(entity); } else if (handler.isBlockMultiSelectionPressed) { if (_lastSelectedItemIndex != -1) { int start = _lastSelectedItemIndex; @@ -172,19 +192,28 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac } for (int i = start; i <= end; i++) { - selectedEntities.add(entities[i]); + entityNotifier.add(entities[i]); } } } else { _lastSelectedItemIndex = index; - selectedEntities.replace(entity); + entityNotifier.replace(entity); } } @override void delete() { var fileEvents = ref.read(fileEventsProvider.notifier); - fileEvents.deleteAll(ref.watch(selectedEntitiesProvider(FileType.previewPane))); + fileEvents.deleteAll(selectedEntities.toSet()); + } + + @override + void down() { + if (_lastSelectedItemIndex < entities.length - gridColumns) { + _selectEntity(entities[_lastSelectedItemIndex + gridColumns]); + + _ensureSelectedItemVisible(); + } } @override @@ -196,6 +225,8 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac void left() { if (_lastSelectedItemIndex > 0) { _selectEntity(entities[--_lastSelectedItemIndex]); + + _ensureSelectedItemVisible(); } } @@ -203,6 +234,8 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac void right() { if (_lastSelectedItemIndex < entities.length) { _selectEntity(entities[++_lastSelectedItemIndex]); + + _ensureSelectedItemVisible(); } } @@ -211,9 +244,31 @@ class _PreviewGrid extends ConsumerState implements KeyboardCallbac } + @override + void removeTag(Tag tag) { + for (var e in selectedEntities) { + ref.read(metadataProvider(e).notifier).removeTags(tag); + } + } + @override void selectAll() { - var selectedEntities = ref.read(selectedEntitiesProvider(FileType.previewPane).notifier); selectedEntities.addAll(entities.toSet()); } + + @override + void up() { + if (_lastSelectedItemIndex >= gridColumns) { + _selectEntity(entities[_lastSelectedItemIndex - gridColumns]); + + _ensureSelectedItemVisible(); + } + } + + @override + void updateTags(String tags) { + for (var e in selectedEntities) { + ref.read(metadataProvider(e).notifier).updateTagsFromString(tags); + } + } } diff --git a/lib/widgets/preview_pane.dart b/lib/widgets/preview_pane.dart index bcf1b6c..acb1b90 100644 --- a/lib/widgets/preview_pane.dart +++ b/lib/widgets/preview_pane.dart @@ -2,24 +2,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../interfaces/keyboard_callback.dart'; +import '../interfaces/tag_handler.dart'; import '../misc/keyboard_handler.dart'; import '../models/file_of_interest.dart'; +import '../models/tag.dart'; +import '../providers/contents/pane_contents.dart'; +import '../providers/contents/pane_tags.dart'; import '../providers/file_events.dart'; -import '../providers/selected_entities/selected_entities.dart'; -import '../providers/selected_entities/selected_previewable_entities.dart'; +import '../providers/metadata.dart'; import 'entity_preview.dart'; import 'entity_context_menu.dart'; import 'metadata/metadata_editor.dart'; class PreviewPane extends ConsumerStatefulWidget { final FileOfInterest initialEntity; - const PreviewPane({Key? key, required this.initialEntity}) : super(key: key); + const PreviewPane({super.key, required this.initialEntity}); @override ConsumerState createState() => _PreviewPane(); } -class _PreviewPane extends ConsumerState implements KeyboardCallback { +class _PreviewPane extends ConsumerState implements KeyboardCallback, TagHandler { late List entities; late PageController _controller; late KeyboardHandler handler; @@ -27,7 +30,7 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac @override Widget build(BuildContext context) { - entities = ref.watch(selectedPreviewableEntitiesProvider(FileType.previewPane)); + entities = ref.watch(paneContentsProvider); // First time through, we set the initial image to the one clicked on. if (_lastSelectedItemIndex == -1) { @@ -35,6 +38,16 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac _controller = PageController(initialPage: _lastSelectedItemIndex); } + if (_lastSelectedItemIndex == -1) { + // This means that the initialEntry widget isn't in the list - because we deleted the file; + // exit the build() in this case as it's a side effect of the Provider build from the file + // delete. TODO: is there a better way to deal with this? + exit(); + + // Avoid a rebuild while we wait for the Context to pop. + return const SizedBox.shrink(); + } + return Scaffold( appBar: AppBar( elevation: 2, @@ -44,7 +57,6 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac body: Row(children: [ Expanded( child: EntityContextMenu( - fileType: FileType.previewPane, child: MouseRegion( onEnter: (_) => handler.hasFocus = true, onExit: (_) => handler.hasFocus = false, @@ -53,7 +65,7 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac ), ), const VerticalDivider(), - SizedBox(width: 200, child: MetadataEditor(completeListType: FileType.previewPane, selectedListType: FileType.previewItem, callback: this,)), + SizedBox(width: 210, child: MetadataEditor(keyHandlerCallback: this, tagHandler: this, paneEntity: entities[_lastSelectedItemIndex])), ])); } @@ -70,7 +82,7 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac Future(() { // Update the selected previewItem to show correct metadata - ref.read(selectedEntitiesProvider(FileType.previewItem).notifier).replace(widget.initialEntity); + ref.read(paneTagsProvider.notifier).replace(widget.initialEntity); }); handler = KeyboardHandler(ref: ref, keyboardCallback: this); @@ -88,11 +100,11 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac onPageChanged: (index) { _lastSelectedItemIndex = index; - ref.read(selectedEntitiesProvider(FileType.previewItem).notifier).replace(entities[index]); + ref.read(paneTagsProvider.notifier).replace(entities[index]); }, itemCount: entities.length, itemBuilder: (BuildContext context, int pos) { - return EntityPreview(entity: entities[pos], isSelected: false, displayMetadata: false,); + return EntityPreview(entity: entities[pos], isSelected: false, displayMetadata: false, previewWidth: MediaQuery.of(context).size.width - 210); }, ), Align( @@ -119,19 +131,26 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac if (_lastSelectedItemIndex != -1) { var fileEvents = ref.read(fileEventsProvider.notifier); - if (_lastSelectedItemIndex == entities.length) { + // Get the entity, reset the selected entity to the previous one, or exit if it was the last one. Then delete the file. + final FileOfInterest entity = entities[_lastSelectedItemIndex]; + if (_lastSelectedItemIndex >= entities.length-1) { _lastSelectedItemIndex--; } - fileEvents.delete(entities[_lastSelectedItemIndex], deleteEntity: true); - if (ref.watch(selectedEntitiesProvider(FileType.previewPane)).isEmpty) { + + if (_lastSelectedItemIndex == -1) { exit(); } + + fileEvents.delete(entity, deleteEntity: true); } } + @override + void down() { + } + @override void exit() { - ref.read(selectedEntitiesProvider(FileType.previewPane).notifier).removeAll(); Navigator.of(context, rootNavigator: true).maybePop(context); } @@ -146,6 +165,11 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac void newEntity() { } + @override + void removeTag(Tag tag) { + ref.read(metadataProvider(entities[_lastSelectedItemIndex]).notifier).removeTags(tag); + } + @override void right() { if (_lastSelectedItemIndex < entities.length - 1) { @@ -153,6 +177,16 @@ class _PreviewPane extends ConsumerState implements KeyboardCallbac } } + @override + void up() { + } + + @override + void updateTags(String tags) { + ref.read(metadataProvider(entities[_lastSelectedItemIndex]).notifier).updateTagsFromString(tags, updateFile: true); + ref.read(paneTagsProvider.notifier).replace(entities[_lastSelectedItemIndex]); + } + @override void selectAll() { } diff --git a/lib/widgets/shackleton.dart b/lib/widgets/shackleton.dart index a037013..99b7936 100644 --- a/lib/widgets/shackleton.dart +++ b/lib/widgets/shackleton.dart @@ -3,15 +3,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart'; -import 'package:shackleton/providers/error.dart'; import '../models/file_of_interest.dart'; +import '../models/folder_ui_settings.dart'; import '../models/map_settings.dart'; import '../models/preview_settings.dart'; import '../providers/folder_path.dart'; +import '../providers/error.dart'; import '../providers/map_pane.dart'; import '../providers/preview.dart'; -import '../providers/selected_entities/selected_entities.dart'; +import '../providers/contents/selected_folder_contents.dart'; +import '../repositories/folder_settings_repository.dart'; + import 'folder_list.dart'; import 'import_folder.dart'; import 'navigation.dart'; @@ -19,7 +22,7 @@ import 'preview_grid.dart'; import 'shackleton_settings.dart'; class Shackleton extends ConsumerStatefulWidget { - const Shackleton({Key? key}) : super(key: key); + const Shackleton({super.key}); @override ConsumerState createState() => _Shackleton(); @@ -35,24 +38,26 @@ class _Shackleton extends ConsumerState { MapSettings map = ref.watch(mapPaneProvider); String error = ref.watch(errorProvider); - return Scaffold( - appBar: AppBar( - elevation: 2, - shadowColor: Theme.of(context).shadowColor, - title: Text(paths.map((e) => basename(e.path)).toList().toString(), style: Theme.of(context).textTheme.labelSmall), - actions: [ - IconButton(icon: const Icon(Icons.import_export), tooltip: 'Import images from folder...', onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => const ImportFolder()))), - IconButton(icon: const Icon(Icons.sync), tooltip: 'Cache metadata...', onPressed: () => _cacheMetadata(ref)), - IconButton(icon: const Icon(Icons.map), tooltip: 'Show on Map', onPressed: () => ref.read(mapPaneProvider.notifier).setVisibility(!map.visible)), - IconButton(icon: const Icon(Icons.preview), tooltip: 'Preview', onPressed: () => ref.read(previewProvider.notifier).setVisibility(!preview.visible)), - IconButton(icon: const Icon(Icons.settings), tooltip: 'Settings', onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => ShackletonSettings()))), - ], - ), - body: Padding( - padding: const EdgeInsets.only(top: 6, bottom: 6), - child: Column(children: [ + Widget widgetState = Scaffold( + appBar: AppBar( + elevation: 2, + shadowColor: Theme.of(context).shadowColor, + title: Text(paths.map((e) => basename(e.path)).toList().toString(), style: Theme.of(context).textTheme.labelSmall), + actions: [ + IconButton(icon: const Icon(Icons.import_export), tooltip: 'Import images from folder...', onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => const ImportFolder()))), + IconButton(icon: const Icon(Icons.sync), tooltip: 'Cache metadata...', onPressed: () => _cacheMetadata(ref)), + IconButton(icon: const Icon(Icons.map), tooltip: 'Show on Map', onPressed: () => ref.read(mapPaneProvider.notifier).setVisibility(!map.visible)), + IconButton(icon: const Icon(Icons.preview), tooltip: 'Preview', onPressed: () => ref.read(previewProvider.notifier).setVisibility(!preview.visible)), + IconButton(icon: const Icon(Icons.settings), tooltip: 'Settings', onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (context) => ShackletonSettings()))), + ], + ), + body: Padding( + padding: const EdgeInsets.only(top: 6, bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ if (preview.visible) ...{ - SizedBox(height: preview.height, child: const PreviewGrid()), + Align(alignment: Alignment.center, child: SizedBox(height: preview.height, child: const PreviewGrid())), MouseRegion( cursor: SystemMouseCursors.resizeRow, child: GestureDetector( @@ -62,31 +67,66 @@ class _Shackleton extends ConsumerState { child: Container(color: const Color.fromRGBO(217, 217, 217, 100), height: 3), )), }, - Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: scrollController, - child: ListView( - controller: scrollController, - scrollDirection: Axis.horizontal, - children: [ - const Navigation(), - ...paths.map((e) => FolderList(path: e)).toList(), - ], - ), - ), + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: ListView.builder( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: paths.length + 1, + itemBuilder: (context, index) { + //folderKeys[index] = GlobalKey(); + return index == 0 ? const Navigation() : FolderList(path: paths[index - 1]); + }), ), - if (error.isNotEmpty) - Expanded( - child: Text(error, style: Theme.of(context).textTheme.labelSmall) - ), - ]), - ), + ), + ), + if (error.isNotEmpty) + Container( + color: Theme.of(context).colorScheme.error, + width: MediaQuery.of(context).size.width, + child: Text(error, style: Theme.of(context).textTheme.labelSmall, textAlign: TextAlign.center,), + ), + ]), + ), ); + + _ensureLatestFolderIsVisible(context); + + return widgetState; + } + + // My single biggest problem with Flutter is how difficult it is to ensure the list value you want to be visible is actually visible. + // Google, with the brainpower you have available, surely you can solve this nicely? And if anyone starts on GlobalKeys. Well, don't. + void _ensureLatestFolderIsVisible(BuildContext context) { + List paths = ref.watch(folderPathProvider); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (mounted) { + final double screenWidth = MediaQuery.of(context).size.width; + double totalWidth = 0; + + var folderSettings = ref.watch(folderSettingsRepositoryProvider(navigationFolder)); + folderSettings.whenData((value) => totalWidth += value.width); + + for (var path in paths) { + var uiSettings = ref.watch(folderSettingsRepositoryProvider(path.path)); + uiSettings.whenData((value) => totalWidth += value.width); + } + + if (totalWidth > screenWidth) { + scrollController.animateTo(totalWidth - screenWidth, duration: const Duration(milliseconds: 500), curve: Curves.decelerate); + } + } + }); } void _cacheMetadata(WidgetRef ref) async { - Set selectedEntities = ref.read(selectedEntitiesProvider(FileType.folderList)); + Set selectedEntities = ref.read(selectedFolderContentsProvider); for (FileOfInterest foi in selectedEntities) { await foi.cacheFileOfInterest(ref); } diff --git a/lib/widgets/shackleton_settings.dart b/lib/widgets/shackleton_settings.dart index 7308f47..607621c 100644 --- a/lib/widgets/shackleton_settings.dart +++ b/lib/widgets/shackleton_settings.dart @@ -10,7 +10,7 @@ class ShackletonSettings extends ConsumerWidget { final TextEditingController fontSizeController = TextEditingController(); final TextEditingController libraryFolderController = TextEditingController(); - ShackletonSettings({Key? key,}) : super(key: key); + ShackletonSettings({super.key,}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/shackleton_statistics.dart b/lib/widgets/shackleton_statistics.dart index 229f0ef..b4bdc89 100644 --- a/lib/widgets/shackleton_statistics.dart +++ b/lib/widgets/shackleton_statistics.dart @@ -5,7 +5,7 @@ import 'package:shackleton/models/app_statistics.dart'; import '../repositories/app_statistics_repository.dart'; class ShackletonStatistics extends ConsumerWidget { - const ShackletonStatistics({Key? key,}) : super(key: key); + const ShackletonStatistics({super.key,}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 06501d7..cc1f382 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,8 +9,10 @@ #include #include #include +#include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = @@ -22,10 +24,16 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) media_kit_video_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); + screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2617a90..019dac1 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,8 +6,10 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context media_kit_libs_linux media_kit_video + screen_retriever super_native_extensions url_launcher_linux + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6411e60..c5d9d05 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,10 +12,12 @@ import media_kit_video import package_info_plus import path_provider_foundation import screen_brightness_macos +import screen_retriever import super_native_extensions import syncfusion_pdfviewer_macos import url_launcher_macos import wakelock_plus +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) @@ -25,8 +27,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) + ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 004064f..aa45760 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -203,7 +203,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index fb28bcb..79f5745 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ =3.0.6' @@ -11,6 +11,7 @@ dependencies: sdk: flutter archive: ^3.3.7 async: ^2.11.0 + collection: ^1.18.0 convert: ^3.1.1 crypto: ^3.0.3 cupertino_icons: ^1.0.2 @@ -20,10 +21,10 @@ dependencies: flutter_riverpod: ^2.3.7 freezed: ^2.4.2 freezed_annotation: ^2.4.1 - http: ^1.1.0 + http: ^1.2.1 image: ^4.0.17 intersperse: ^2.0.0 - intl: ^0.18.1 + intl: ^0.19.0 json_annotation: ^4.8.1 latlong2: ^0.9.0 logger: ^2.0.1 @@ -35,28 +36,31 @@ dependencies: media_kit_libs_linux: ^1.1.0 path: ^1.8.3 path_provider: ^2.1.0 - process_run: ^0.13.1 + process_run: ^0.14.0+1 riverpod_annotation: ^2.1.2 + scrollable_positioned_list: ^0.3.8 sqflite_common_ffi: ^2.3.0+2 - super_context_menu: ^0.6.0 - super_drag_and_drop: ^0.6.0 - syncfusion_flutter_gauges: ^23.1.38 - syncfusion_flutter_pdfviewer: ^23.1.38 + super_context_menu: ^0.8.2+1 + super_drag_and_drop: ^0.8.2+1 + syncfusion_flutter_gauges: ^24.1.41 + syncfusion_flutter_pdfviewer: ^24.1.41 synchronized: ^3.1.0 universal_disk_space: ^0.2.3 url_launcher: ^6.1.12 + version: ^3.0.2 + window_manager: ^0.3.8 dev_dependencies: build_runner: ^2.4.6 - custom_lint: ^0.5.1 + custom_lint: ^0.6.2 flutter_launcher_icons: ^0.13.1 - flutter_lints: ^2.0.2 + flutter_lints: ^3.0.1 flutter_test: sdk: flutter json_serializable: ^6.7.1 innosetup: ^0.1.3 - riverpod_lint: ^2.0.1 - riverpod_generator: ^2.2.6 + riverpod_lint: ^2.3.9 + riverpod_generator: ^2.3.11 flutter: uses-material-design: true diff --git a/test/metadata_test.dart b/test/metadata_test.dart index c3c6a96..6ca2f7f 100644 --- a/test/metadata_test.dart +++ b/test/metadata_test.dart @@ -2,6 +2,10 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:shackleton/misc/utils.dart'; +import 'package:shackleton/models/file_metadata.dart'; import 'package:shackleton/models/file_of_interest.dart'; import 'package:shackleton/providers/metadata.dart'; @@ -35,4 +39,15 @@ void main() { expectedTags = 3; container.read(metadataProvider(foi).notifier).updateTagsFromString('one, two', updateFile: false); }); + + test('validate longitude and latitude processing', () async { + LatLng ll = LatLng(double.parse('43.6597083333333'), double.parse('-78.5631361111111')); + + FileMetaData fmd = FileMetaData(tags: const [], gpsLocation: ll); + String latitude = getLocation(fmd, true).replaceAll("'", "\\'").replaceAll('"', '\\"'); + String longitude = getLocation(fmd, false).replaceAll("'", "\\'").replaceAll('"', '\\"'); + + expect(latitude, '43 deg 39\\\' 34.95\\" N'); + expect(longitude, '78 deg 33\\\' 47.29\\" W'); + }); } \ No newline at end of file diff --git a/test/selected_entities_test.dart b/test/selected_entities_test.dart index bfa23d1..b6b2f2b 100644 --- a/test/selected_entities_test.dart +++ b/test/selected_entities_test.dart @@ -3,19 +3,19 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shackleton/models/file_of_interest.dart'; -import 'package:shackleton/providers/selected_entities/selected_entities.dart'; +import 'package:shackleton/providers/contents/selected_grid_entities.dart'; void main() { test('can add files to selectedEntity provider', () async { final ProviderContainer container = ProviderContainer(); int expectedEntitiesLength = 0; - container.listen(selectedEntitiesProvider(FileType.previewPane), (previous, next) { + container.listen(selectedGridEntitiesProvider, (previous, next) { expect(next.length, expectedEntitiesLength); }); expectedEntitiesLength = 5; - container.read(selectedEntitiesProvider(FileType.previewPane).notifier).addAll({ + container.read(selectedGridEntitiesProvider.notifier).addAll({ FileOfInterest(entity: File('aaa')), FileOfInterest(entity: File('bbb')), FileOfInterest(entity: File('/a/b/c/a')), @@ -24,12 +24,12 @@ void main() { }); expectedEntitiesLength = 4; - container.read(selectedEntitiesProvider(FileType.previewPane).notifier).remove( + container.read(selectedGridEntitiesProvider.notifier).remove( FileOfInterest(entity: File('bbb')), ); expectedEntitiesLength = 2; - container.read(selectedEntitiesProvider(FileType.previewPane).notifier).remove( + container.read(selectedGridEntitiesProvider.notifier).remove( FileOfInterest(entity: Directory('/a/b/c')), ); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index dbe9ce8..43fd1c0 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,23 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; - -import '../lib/main.dart'; +import 'package:shackleton/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const ProviderScope(child: ShackletonApp())); // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + expect(find.text('Name'), findsOneWidget); // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + await tester.drag(find.byKey(const Key('resize')), const Offset(100, 0)); + await tester.pumpAndSettle(); // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Name'), findsOneWidget); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4f9272c..ed26e78 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,9 +10,11 @@ #include #include #include +#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { IrondashEngineContextPluginCApiRegisterWithRegistrar( @@ -23,10 +25,14 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); ScreenBrightnessWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); + ScreenRetrieverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); SyncfusionPdfviewerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SyncfusionPdfviewerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 131db7a..93df213 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,9 +7,11 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_windows_video media_kit_video screen_brightness_windows + screen_retriever super_native_extensions syncfusion_pdfviewer_windows url_launcher_windows + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST