From f3901b5ff0361720fda1fa1fcc7b77cc7b245b06 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 13 Apr 2023 23:32:31 +0800 Subject: [PATCH] Rewrite collection support and add NC25 album support --- app/assets/2.0x/ic_nextcloud_album.png | Bin 0 -> 606 bytes app/assets/3.0x/ic_nextcloud_album.png | Bin 0 -> 872 bytes app/assets/ic_nextcloud_album.png | Bin 0 -> 344 bytes app/lib/api/entity_converter.dart | 17 + app/lib/app_init.dart | 14 + app/lib/bloc/home_search_suggestion.dart | 40 +- app/lib/bloc/home_search_suggestion.g.dart | 4 +- app/lib/bloc/list_album.dart | 338 --------- app/lib/bloc/list_album.g.dart | 46 -- app/lib/bloc/list_face_file.dart | 222 ------ app/lib/bloc/list_face_file.g.dart | 47 -- app/lib/bloc/list_importable_album.dart | 2 +- app/lib/bloc/list_location.dart | 3 +- app/lib/bloc/list_location_file.dart | 181 ----- app/lib/bloc/list_location_file.g.dart | 47 -- app/lib/bloc/list_tag_file.dart | 226 ------ app/lib/bloc/list_tag_file.g.dart | 47 -- app/lib/bloc/scan_account_dir.dart | 2 +- app/lib/bloc/search.dart | 3 +- app/lib/bloc_util.dart | 3 + app/lib/controller/account_controller.dart | 23 + .../collection_items_controller.dart | 336 +++++++++ .../collection_items_controller.g.dart | 49 ++ .../controller/collections_controller.dart | 274 ++++++++ .../controller/collections_controller.g.dart | 75 ++ app/lib/controller/pref_controller.dart | 54 ++ .../pref_controller.g.dart} | 6 +- app/lib/di_container.dart | 104 +++ app/lib/entity/album/data_source.dart | 288 +++----- app/lib/entity/album/data_source2.dart | 247 +++++++ app/lib/entity/album/data_source2.g.dart | 22 + app/lib/entity/album/repo2.dart | 150 ++++ .../album/repo2.g.dart} | 6 +- app/lib/entity/album/sort_provider.dart | 117 ++-- app/lib/entity/album_util.dart | 41 -- app/lib/entity/collection.dart | 93 +++ app/lib/entity/collection.g.dart | 48 ++ app/lib/entity/collection/adapter.dart | 83 +++ app/lib/entity/collection/adapter/album.dart | 207 ++++++ .../entity/collection/adapter/album.g.dart | 15 + .../collection/adapter/location_group.dart | 56 ++ .../entity/collection/adapter/nc_album.dart | 151 ++++ .../entity/collection/adapter/nc_album.g.dart | 15 + app/lib/entity/collection/adapter/person.dart | 58 ++ .../collection/adapter/read_only_adapter.dart | 42 ++ app/lib/entity/collection/adapter/tag.dart | 45 ++ app/lib/entity/collection/builder.dart | 64 ++ .../collection/content_provider/album.dart | 75 ++ .../collection/content_provider/album.g.dart | 48 ++ .../content_provider/location_group.dart | 44 ++ .../collection/content_provider/nc_album.dart | 62 ++ .../content_provider/nc_album.g.dart | 48 ++ .../collection/content_provider/person.dart | 42 ++ .../collection/content_provider/tag.dart | 36 + app/lib/entity/collection/util.dart | 43 ++ app/lib/entity/collection_item.dart | 22 + .../collection_item/album_item_adapter.dart | 57 ++ .../collection_item/album_item_adapter.g.dart | 23 + .../entity/collection_item/basic_item.dart | 17 + .../entity/collection_item/basic_item.g.dart | 14 + app/lib/entity/collection_item/new_item.dart | 42 ++ .../entity/collection_item/new_item.g.dart | 21 + app/lib/entity/collection_item/sorter.dart | 148 ++++ app/lib/entity/collection_item/sorter.g.dart | 15 + app/lib/entity/collection_item/util.dart | 7 + app/lib/entity/file.dart | 4 +- app/lib/entity/file/data_source.dart | 12 +- app/lib/entity/file/file_cache_manager.dart | 2 +- app/lib/entity/file_descriptor.dart | 11 + app/lib/entity/file_util.dart | 19 +- app/lib/entity/nc_album.dart | 100 +++ app/lib/entity/nc_album.g.dart | 69 ++ app/lib/entity/nc_album/data_source.dart | 241 +++++++ app/lib/entity/nc_album/data_source.g.dart | 23 + app/lib/entity/nc_album/item.dart | 5 + app/lib/entity/nc_album/repo.dart | 140 ++++ app/lib/entity/nc_album/repo.g.dart | 21 + app/lib/entity/sqlite/database.dart | 5 +- app/lib/entity/sqlite/database.g.dart | 639 ++++++++++++++++- .../sqlite/database/nc_album_extension.dart | 137 ++++ app/lib/entity/sqlite/database_extension.dart | 91 ++- app/lib/entity/sqlite/table.dart | 31 + app/lib/entity/sqlite/type_converter.dart | 51 ++ app/lib/event/event.dart | 3 +- app/lib/exception.dart | 11 +- app/lib/flutter_util.dart | 2 + app/lib/lazy.dart | 16 + app/lib/list_extension.dart | 4 + app/lib/main.dart | 6 +- app/lib/material3.dart | 43 -- app/lib/rx_extension.dart | 7 + app/lib/theme.dart | 6 - .../add_file_to_album.dart} | 29 +- .../use_case/album/add_file_to_album.g.dart | 14 + .../use_case/{ => album}/create_album.dart | 0 app/lib/use_case/album/edit_album.dart | 54 ++ .../edit_album.g.dart} | 6 +- app/lib/use_case/{ => album}/list_album.dart | 0 app/lib/use_case/album/list_album2.dart | 78 +++ .../album/list_album2.g.dart} | 6 +- .../use_case/{ => album}/remove_album.dart | 0 .../use_case/{ => album}/remove_album.g.dart | 2 +- .../{ => album}/remove_from_album.dart | 30 +- .../{ => album}/remove_from_album.g.dart | 2 +- app/lib/use_case/archive_file.dart | 72 ++ app/lib/use_case/archive_file.g.dart | 14 + .../collection/add_file_to_collection.dart | 28 + .../collection/create_collection.dart | 34 + .../use_case/collection/edit_collection.dart | 34 + .../use_case/collection/list_collection.dart | 73 ++ .../collection/list_collection_item.dart | 21 + .../collection/list_collection_item.g.dart | 15 + .../collection/remove_collections.dart | 43 ++ .../collection/remove_collections.g.dart | 15 + .../collection/remove_from_collection.dart | 27 + app/lib/use_case/download_file.dart | 9 +- app/lib/use_case/download_preview.dart | 4 +- app/lib/use_case/find_file_descriptor.dart | 54 ++ app/lib/use_case/find_file_descriptor.g.dart | 14 + app/lib/use_case/list_face.dart | 15 + app/lib/use_case/list_favorite.dart | 43 -- app/lib/use_case/list_favorite_offline.dart | 20 - .../nc_album/add_file_to_nc_album.dart | 44 ++ .../nc_album/add_file_to_nc_album.g.dart | 15 + .../use_case/nc_album/create_nc_album.dart | 14 + app/lib/use_case/nc_album/edit_nc_album.dart | 37 + .../nc_album/edit_nc_album.g.dart} | 6 +- app/lib/use_case/nc_album/list_nc_album.dart | 15 + .../use_case/nc_album/list_nc_album_item.dart | 15 + .../nc_album/remove_from_nc_album.dart | 67 ++ .../nc_album/remove_from_nc_album.g.dart | 15 + .../use_case/nc_album/remove_nc_album.dart | 14 + app/lib/use_case/remove.dart | 28 +- app/lib/use_case/unshare_file_from_album.dart | 2 +- app/lib/widget/album_browser.dart | 16 +- app/lib/widget/album_importer.dart | 2 +- app/lib/widget/album_picker.dart | 278 -------- .../builder/album_grid_item_builder.dart | 100 --- app/lib/widget/collection_browser.dart | 373 ++++++++++ app/lib/widget/collection_browser.g.dart | 265 +++++++ .../widget/collection_browser/app_bar.dart | 360 ++++++++++ app/lib/widget/collection_browser/bloc.dart | 430 ++++++++++++ .../collection_browser/state_event.dart | 257 +++++++ app/lib/widget/collection_browser/type.dart | 176 +++++ ...id_item.dart => collection_grid_item.dart} | 20 +- app/lib/widget/collection_picker.dart | 289 ++++++++ app/lib/widget/collection_picker.g.dart | 122 ++++ app/lib/widget/collection_picker/bloc.dart | 60 ++ .../widget/collection_picker/state_event.dart | 77 +++ app/lib/widget/collection_picker/type.dart | 19 + app/lib/widget/draggable_item_list.dart | 100 +++ app/lib/widget/draggable_item_list.g.dart | 26 + app/lib/widget/dynamic_album_browser.dart | 11 +- app/lib/widget/file_sharer.dart | 370 ++++++++++ app/lib/widget/file_sharer.g.dart | 212 ++++++ app/lib/widget/file_sharer/bloc.dart | 209 ++++++ app/lib/widget/file_sharer/state_event.dart | 137 ++++ app/lib/widget/file_sharer/type.dart | 8 + .../add_selection_to_album_handler.dart | 70 -- .../add_selection_to_collection_handler.dart | 59 ++ ...dd_selection_to_collection_handler.g.dart} | 7 +- .../handler/remove_selection_handler.dart | 4 +- app/lib/widget/home.dart | 6 +- app/lib/widget/home_albums.dart | 653 ------------------ app/lib/widget/home_collections.dart | 569 +++++++++++++++ app/lib/widget/home_collections.g.dart | 145 ++++ app/lib/widget/home_collections/bloc.dart | 101 +++ .../widget/home_collections/state_event.dart | 106 +++ app/lib/widget/home_collections/type.dart | 67 ++ app/lib/widget/home_photos.dart | 11 +- app/lib/widget/home_search.dart | 6 +- app/lib/widget/home_search_suggestion.dart | 67 +- app/lib/widget/my_app.dart | 167 ++--- app/lib/widget/my_app.g.dart | 4 +- .../widget/navigation_bar_blur_filter.dart | 28 + app/lib/widget/new_album_dialog.dart | 291 -------- app/lib/widget/new_album_dialog.g.dart | 14 - app/lib/widget/new_collection_dialog.dart | 280 ++++++++ app/lib/widget/new_collection_dialog.g.dart | 134 ++++ .../widget/new_collection_dialog/bloc.dart | 115 +++ .../new_collection_dialog/state_event.dart | 107 +++ app/lib/widget/people_browser.dart | 12 +- app/lib/widget/person_browser.dart | 463 ------------- app/lib/widget/person_browser.g.dart | 14 - app/lib/widget/photo_list_item.dart | 4 +- app/lib/widget/place_browser.dart | 411 ----------- app/lib/widget/place_browser.g.dart | 14 - app/lib/widget/places_browser.dart | 25 +- app/lib/widget/search_landing.dart | 20 +- app/lib/widget/selectable_item_list.dart | 225 ++++++ app/lib/widget/selectable_item_list.g.dart | 15 + app/lib/widget/smart_album_browser.dart | 6 +- app/lib/widget/tag_browser.dart | 425 ------------ app/lib/widget/viewer.dart | 2 +- app/lib/widget/viewer_detail_pane.dart | 6 +- app/pubspec.lock | 18 +- app/pubspec.yaml | 2 + app/test/entity/album/sort_provider_test.dart | 439 ------------ app/test/entity/sqlite/database_test.dart | 2 + app/test/mock_type.dart | 13 +- app/test/use_case/add_file_to_album_test.dart | 375 ++++++++++ app/test/use_case/add_to_album_test.dart | 401 ----------- app/test/use_case/remove_album_test.dart | 2 +- app/test/use_case/remove_from_album_test.dart | 2 +- np_api/lib/np_api.dart | 1 + np_api/lib/src/api.dart | 4 + np_api/lib/src/api.g.dart | 21 + np_api/lib/src/entity/entity.dart | 50 ++ np_api/lib/src/entity/entity.g.dart | 14 + np_api/lib/src/entity/nc_album_parser.dart | 103 +++ np_api/lib/src/entity/status_parser.dart | 18 + np_api/lib/src/files_api.dart | 2 +- np_api/lib/src/photos_api.dart | 202 ++++++ np_api/lib/src/status_api.dart | 18 + np_common/lib/type.dart | 8 + 215 files changed, 12187 insertions(+), 5480 deletions(-) create mode 100644 app/assets/2.0x/ic_nextcloud_album.png create mode 100644 app/assets/3.0x/ic_nextcloud_album.png create mode 100644 app/assets/ic_nextcloud_album.png delete mode 100644 app/lib/bloc/list_album.dart delete mode 100644 app/lib/bloc/list_album.g.dart delete mode 100644 app/lib/bloc/list_face_file.dart delete mode 100644 app/lib/bloc/list_face_file.g.dart delete mode 100644 app/lib/bloc/list_location_file.dart delete mode 100644 app/lib/bloc/list_location_file.g.dart delete mode 100644 app/lib/bloc/list_tag_file.dart delete mode 100644 app/lib/bloc/list_tag_file.g.dart create mode 100644 app/lib/bloc_util.dart create mode 100644 app/lib/controller/account_controller.dart create mode 100644 app/lib/controller/collection_items_controller.dart create mode 100644 app/lib/controller/collection_items_controller.g.dart create mode 100644 app/lib/controller/collections_controller.dart create mode 100644 app/lib/controller/collections_controller.g.dart create mode 100644 app/lib/controller/pref_controller.dart rename app/lib/{widget/album_picker.g.dart => controller/pref_controller.g.dart} (63%) create mode 100644 app/lib/entity/album/data_source2.dart create mode 100644 app/lib/entity/album/data_source2.g.dart create mode 100644 app/lib/entity/album/repo2.dart rename app/lib/{use_case/list_favorite.g.dart => entity/album/repo2.g.dart} (66%) delete mode 100644 app/lib/entity/album_util.dart create mode 100644 app/lib/entity/collection.dart create mode 100644 app/lib/entity/collection.g.dart create mode 100644 app/lib/entity/collection/adapter.dart create mode 100644 app/lib/entity/collection/adapter/album.dart create mode 100644 app/lib/entity/collection/adapter/album.g.dart create mode 100644 app/lib/entity/collection/adapter/location_group.dart create mode 100644 app/lib/entity/collection/adapter/nc_album.dart create mode 100644 app/lib/entity/collection/adapter/nc_album.g.dart create mode 100644 app/lib/entity/collection/adapter/person.dart create mode 100644 app/lib/entity/collection/adapter/read_only_adapter.dart create mode 100644 app/lib/entity/collection/adapter/tag.dart create mode 100644 app/lib/entity/collection/builder.dart create mode 100644 app/lib/entity/collection/content_provider/album.dart create mode 100644 app/lib/entity/collection/content_provider/album.g.dart create mode 100644 app/lib/entity/collection/content_provider/location_group.dart create mode 100644 app/lib/entity/collection/content_provider/nc_album.dart create mode 100644 app/lib/entity/collection/content_provider/nc_album.g.dart create mode 100644 app/lib/entity/collection/content_provider/person.dart create mode 100644 app/lib/entity/collection/content_provider/tag.dart create mode 100644 app/lib/entity/collection/util.dart create mode 100644 app/lib/entity/collection_item.dart create mode 100644 app/lib/entity/collection_item/album_item_adapter.dart create mode 100644 app/lib/entity/collection_item/album_item_adapter.g.dart create mode 100644 app/lib/entity/collection_item/basic_item.dart create mode 100644 app/lib/entity/collection_item/basic_item.g.dart create mode 100644 app/lib/entity/collection_item/new_item.dart create mode 100644 app/lib/entity/collection_item/new_item.g.dart create mode 100644 app/lib/entity/collection_item/sorter.dart create mode 100644 app/lib/entity/collection_item/sorter.g.dart create mode 100644 app/lib/entity/collection_item/util.dart create mode 100644 app/lib/entity/nc_album.dart create mode 100644 app/lib/entity/nc_album.g.dart create mode 100644 app/lib/entity/nc_album/data_source.dart create mode 100644 app/lib/entity/nc_album/data_source.g.dart create mode 100644 app/lib/entity/nc_album/item.dart create mode 100644 app/lib/entity/nc_album/repo.dart create mode 100644 app/lib/entity/nc_album/repo.g.dart create mode 100644 app/lib/entity/sqlite/database/nc_album_extension.dart create mode 100644 app/lib/lazy.dart create mode 100644 app/lib/rx_extension.dart rename app/lib/use_case/{add_to_album.dart => album/add_file_to_album.dart} (84%) create mode 100644 app/lib/use_case/album/add_file_to_album.g.dart rename app/lib/use_case/{ => album}/create_album.dart (100%) create mode 100644 app/lib/use_case/album/edit_album.dart rename app/lib/use_case/{add_to_album.g.dart => album/edit_album.g.dart} (66%) rename app/lib/use_case/{ => album}/list_album.dart (100%) create mode 100644 app/lib/use_case/album/list_album2.dart rename app/lib/{widget/home_albums.g.dart => use_case/album/list_album2.g.dart} (64%) rename app/lib/use_case/{ => album}/remove_album.dart (100%) rename app/lib/use_case/{ => album}/remove_album.g.dart (82%) rename app/lib/use_case/{ => album}/remove_from_album.dart (80%) rename app/lib/use_case/{ => album}/remove_from_album.g.dart (81%) create mode 100644 app/lib/use_case/archive_file.dart create mode 100644 app/lib/use_case/archive_file.g.dart create mode 100644 app/lib/use_case/collection/add_file_to_collection.dart create mode 100644 app/lib/use_case/collection/create_collection.dart create mode 100644 app/lib/use_case/collection/edit_collection.dart create mode 100644 app/lib/use_case/collection/list_collection.dart create mode 100644 app/lib/use_case/collection/list_collection_item.dart create mode 100644 app/lib/use_case/collection/list_collection_item.g.dart create mode 100644 app/lib/use_case/collection/remove_collections.dart create mode 100644 app/lib/use_case/collection/remove_collections.g.dart create mode 100644 app/lib/use_case/collection/remove_from_collection.dart create mode 100644 app/lib/use_case/find_file_descriptor.dart create mode 100644 app/lib/use_case/find_file_descriptor.g.dart create mode 100644 app/lib/use_case/list_face.dart delete mode 100644 app/lib/use_case/list_favorite.dart delete mode 100644 app/lib/use_case/list_favorite_offline.dart create mode 100644 app/lib/use_case/nc_album/add_file_to_nc_album.dart create mode 100644 app/lib/use_case/nc_album/add_file_to_nc_album.g.dart create mode 100644 app/lib/use_case/nc_album/create_nc_album.dart create mode 100644 app/lib/use_case/nc_album/edit_nc_album.dart rename app/lib/{widget/tag_browser.g.dart => use_case/nc_album/edit_nc_album.g.dart} (64%) create mode 100644 app/lib/use_case/nc_album/list_nc_album.dart create mode 100644 app/lib/use_case/nc_album/list_nc_album_item.dart create mode 100644 app/lib/use_case/nc_album/remove_from_nc_album.dart create mode 100644 app/lib/use_case/nc_album/remove_from_nc_album.g.dart create mode 100644 app/lib/use_case/nc_album/remove_nc_album.dart delete mode 100644 app/lib/widget/album_picker.dart delete mode 100644 app/lib/widget/builder/album_grid_item_builder.dart create mode 100644 app/lib/widget/collection_browser.dart create mode 100644 app/lib/widget/collection_browser.g.dart create mode 100644 app/lib/widget/collection_browser/app_bar.dart create mode 100644 app/lib/widget/collection_browser/bloc.dart create mode 100644 app/lib/widget/collection_browser/state_event.dart create mode 100644 app/lib/widget/collection_browser/type.dart rename app/lib/widget/{album_grid_item.dart => collection_grid_item.dart} (82%) create mode 100644 app/lib/widget/collection_picker.dart create mode 100644 app/lib/widget/collection_picker.g.dart create mode 100644 app/lib/widget/collection_picker/bloc.dart create mode 100644 app/lib/widget/collection_picker/state_event.dart create mode 100644 app/lib/widget/collection_picker/type.dart create mode 100644 app/lib/widget/draggable_item_list.dart create mode 100644 app/lib/widget/draggable_item_list.g.dart create mode 100644 app/lib/widget/file_sharer.dart create mode 100644 app/lib/widget/file_sharer.g.dart create mode 100644 app/lib/widget/file_sharer/bloc.dart create mode 100644 app/lib/widget/file_sharer/state_event.dart create mode 100644 app/lib/widget/file_sharer/type.dart delete mode 100644 app/lib/widget/handler/add_selection_to_album_handler.dart create mode 100644 app/lib/widget/handler/add_selection_to_collection_handler.dart rename app/lib/widget/handler/{add_selection_to_album_handler.g.dart => add_selection_to_collection_handler.g.dart} (56%) delete mode 100644 app/lib/widget/home_albums.dart create mode 100644 app/lib/widget/home_collections.dart create mode 100644 app/lib/widget/home_collections.g.dart create mode 100644 app/lib/widget/home_collections/bloc.dart create mode 100644 app/lib/widget/home_collections/state_event.dart create mode 100644 app/lib/widget/home_collections/type.dart create mode 100644 app/lib/widget/navigation_bar_blur_filter.dart delete mode 100644 app/lib/widget/new_album_dialog.dart delete mode 100644 app/lib/widget/new_album_dialog.g.dart create mode 100644 app/lib/widget/new_collection_dialog.dart create mode 100644 app/lib/widget/new_collection_dialog.g.dart create mode 100644 app/lib/widget/new_collection_dialog/bloc.dart create mode 100644 app/lib/widget/new_collection_dialog/state_event.dart delete mode 100644 app/lib/widget/person_browser.dart delete mode 100644 app/lib/widget/person_browser.g.dart delete mode 100644 app/lib/widget/place_browser.dart delete mode 100644 app/lib/widget/place_browser.g.dart create mode 100644 app/lib/widget/selectable_item_list.dart create mode 100644 app/lib/widget/selectable_item_list.g.dart delete mode 100644 app/lib/widget/tag_browser.dart delete mode 100644 app/test/entity/album/sort_provider_test.dart create mode 100644 app/test/use_case/add_file_to_album_test.dart delete mode 100644 app/test/use_case/add_to_album_test.dart create mode 100644 np_api/lib/src/entity/nc_album_parser.dart create mode 100644 np_api/lib/src/entity/status_parser.dart create mode 100644 np_api/lib/src/photos_api.dart create mode 100644 np_api/lib/src/status_api.dart diff --git a/app/assets/2.0x/ic_nextcloud_album.png b/app/assets/2.0x/ic_nextcloud_album.png new file mode 100644 index 0000000000000000000000000000000000000000..3252e2f6ee9efbc7dcf1e0ad295855952cd95414 GIT binary patch literal 606 zcmV-k0-^nhP)ZJJ-l>hib& z*29PntG2p2E`Y{}=DQ(=eiM};8=5-s3AhkxDK>#Cz}FaQvNZ-H;&cmu44YXF-O z%>zsV8#&_l%|dl`_mtQDUcHtv+vHVCy_z9@t=|-?2I~0%qjdn(3fh;lJbM?o4*WPd zh3;iAPl4vMK6?SU+iMNnE>Q*X$7}Z#YDQj|MXCu*ow;iCV~Ko!=moG{B7p5)YoHo7 zjD-O=?*TIf8QbqF!TaeQaHCfm{SMp=dG@rMZvpKB%(s9>hWJar&G+jE&Z`g9*P%>4 zR$r<2!!^_=1?O6Gn5%_iZZgv79|3c~;os_s4}d#BCxZD-*q9DSO{01B9C$b) zL?=|9FUQ#vFNJxUS4ZmNzt9t-PODq$&x{?|Q}tqu*p;fr06(r;B4q#jUrqPiG4UinQw#&7Zg*R{ejE}KNfM{jj)UG!j) z$Chmg=2bCgc;EV{^=Cdy`($CiV|L23dz1CQ&$H}TQBzu>wc1@!LxFWkgUAG?AO|iF z#+5LJCP*}7_N(rKw{;54bJ*A#oe#);zL)<%djp3^L;8W~8a+LxJD=QQ?_u2DFq!dv zE$1CZIi`AR!3!J{SlOiJq;t+-m{;D#e~Z(H(cig&xjJs6w4+yIzi_Kj zqW#@hHytw{?Bl$3?Q8^#TH|NCLO-)Z4~)6Lm?hNjbXjt#qx;01D`Dqix;tW~zbM_x z`1`b*s)eVZCY!-34JA&&$P7k@R<<|#$q%15KRTeD5P#t9UamK}jemlC^u(`BQ!-$w zS@(3wbDx-ty{8z{S6jX;5kJgm!MtP@z9;eg2(p`MGvN z)|$M>`;-bc&nr5vo?xP|M5%x?0_ZN*Wbc6A?t3dE))pOa=s0t2BIo%IkNrEJM{%co zPUgK4A8z(O^i<3J!v`Ec-!f0QXR&3b?)7g>?+afY34Fm^z*C-V*zq@YQ()2A2V1%p z2duir&Zi|k@r#LH=jNNcq|IcatvTk1`N%E5eSv4M+0w4w4eHybcK>D6dtmZ4=e4q5 zdFkO}Ps}D?6%DqkNH6R)PwSQ6CbnVj+35VG| zj8h*iIeoyQfUASmLq6`opN3Zxx2Ik9Y}odyqjP54*>zTD(p2+#Bh2_J?zQlp4wR6* wTyN|CdWk3~rD-80IZcJBkhG_{VB4koj^v9PU7ghnfw_di)78&qol`;+02mN**#H0l literal 0 HcmV?d00001 diff --git a/app/assets/ic_nextcloud_album.png b/app/assets/ic_nextcloud_album.png new file mode 100644 index 0000000000000000000000000000000000000000..ecc7063d2540638e583b011132241fc23143dd1b GIT binary patch literal 344 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt)eIT^vI^j=!Din8oBM;CjE~ zjE?iPMebKvPTC6DIu>abPP&sgr`4;e`}wlgwl5}X7abDp{`iW2UA^Mp)z}p=!p!S> z_8s`I5x`iyfX$Bi`hnVxrwgB11m3^8Hhv@TMo&Lw^*GkX=DQXv)@R*ZYTR*b1Iw)g ztOm?(2MmlG6b;yxb^bAuyBKuWb?)0M$39qE$v17}-#uP|+|PrEFhAV2qg z<5}~PSuX#IYuKmm3NUZ%f92e68(cP5{nKghn_JG^{4&S2`m|2e+P?Z-8S2ws&D|Ab o&lLVj=lai1*Z+&Zvexw~AMi@}pdr5}ALwBQPgg&ebxsLQ0Gun4ZvX%Q literal 0 HcmV?d00001 diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index dcadfa1d..5afc0c5f 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -4,6 +4,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/nc_album.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; @@ -94,6 +95,22 @@ class ApiFileConverter { static final _log = _$ApiFileConverterNpLog.log; } +class ApiNcAlbumConverter { + static NcAlbum fromApi(api.NcAlbum album) { + return NcAlbum( + path: album.href, + lastPhoto: (album.lastPhoto ?? -1) < 0 ? null : album.lastPhoto, + nbItems: album.nbItems ?? 0, + location: album.location, + dateStart: (album.dateRange?["start"] as int?) + ?.run((d) => DateTime.fromMillisecondsSinceEpoch(d * 1000)), + dateEnd: (album.dateRange?["end"] as int?) + ?.run((d) => DateTime.fromMillisecondsSinceEpoch(d * 1000)), + collaborators: const [], + ); + } +} + class ApiPersonConverter { static Person fromApi(api.Person person) { return Person( diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index 944e67f5..1f72564d 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -8,6 +8,8 @@ import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/data_source.dart'; +import 'package:nc_photos/entity/album/data_source2.dart'; +import 'package:nc_photos/entity/album/repo2.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/face/data_source.dart'; import 'package:nc_photos/entity/favorite.dart'; @@ -16,6 +18,8 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/local_file/data_source.dart'; +import 'package:nc_photos/entity/nc_album/data_source.dart'; +import 'package:nc_photos/entity/nc_album/repo.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/person/data_source.dart'; import 'package:nc_photos/entity/search.dart'; @@ -198,7 +202,12 @@ Future _initDiContainer(InitIsolateType isolateType) async { c.sqliteDb = await _createDb(isolateType); c.albumRepo = AlbumRepo(AlbumCachedDataSource(c)); + c.albumRepoRemote = AlbumRepo(AlbumRemoteDataSource()); c.albumRepoLocal = AlbumRepo(AlbumSqliteDbDataSource(c)); + c.albumRepo2 = CachedAlbumRepo2( + const AlbumRemoteDataSource2(), AlbumSqliteDbDataSource2(c.sqliteDb)); + c.albumRepo2Remote = const BasicAlbumRepo2(AlbumRemoteDataSource2()); + c.albumRepo2Local = BasicAlbumRepo2(AlbumSqliteDbDataSource2(c.sqliteDb)); c.faceRepo = const FaceRepo(FaceRemoteDataSource()); c.fileRepo = FileRepo(FileCachedDataSource(c)); c.fileRepoRemote = const FileRepo(FileWebdavDataSource()); @@ -214,6 +223,11 @@ Future _initDiContainer(InitIsolateType isolateType) async { c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb)); c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource()); c.searchRepo = SearchRepo(SearchSqliteDbDataSource(c)); + c.ncAlbumRepo = CachedNcAlbumRepo( + const NcAlbumRemoteDataSource(), NcAlbumSqliteDbDataSource(c.sqliteDb)); + c.ncAlbumRepoRemote = const BasicNcAlbumRepo(NcAlbumRemoteDataSource()); + c.ncAlbumRepoLocal = BasicNcAlbumRepo(NcAlbumSqliteDbDataSource(c.sqliteDb)); + c.touchManager = TouchManager(c); if (platform_k.isAndroid) { diff --git a/app/lib/bloc/home_search_suggestion.dart b/app/lib/bloc/home_search_suggestion.dart index 20919fbc..4792e86f 100644 --- a/app/lib/bloc/home_search_suggestion.dart +++ b/app/lib/bloc/home_search_suggestion.dart @@ -4,12 +4,13 @@ import 'package:flutter/foundation.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/use_case/list_album.dart'; +import 'package:nc_photos/use_case/collection/list_collection.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; import 'package:nc_photos/use_case/list_person.dart'; import 'package:nc_photos/use_case/list_tag.dart'; @@ -24,13 +25,13 @@ part 'home_search_suggestion.g.dart'; abstract class HomeSearchResult {} @toString -class HomeSearchAlbumResult implements HomeSearchResult { - const HomeSearchAlbumResult(this.album); +class HomeSearchCollectionResult implements HomeSearchResult { + const HomeSearchCollectionResult(this.collection); @override String toString() => _$toString(); - final Album album; + final Collection collection; } @toString @@ -125,7 +126,7 @@ class HomeSearchSuggestionBlocFailure extends HomeSearchSuggestionBlocState { @npLog class HomeSearchSuggestionBloc extends Bloc { - HomeSearchSuggestionBloc(this.account) + HomeSearchSuggestionBloc(this.account, this.collectionsController) : super(const HomeSearchSuggestionBlocInit()) { final c = KiwiContainer().resolve(); assert(require(c)); @@ -187,13 +188,19 @@ class HomeSearchSuggestionBloc Emitter emit) async { final product = <_Searcheable>[]; try { - final albums = await ListAlbum(_c)(account) - .where((event) => event is Album) + var collections = collectionsController + .peekStream() + .data + .map((e) => e.collection) .toList(); - product.addAll(albums.map((a) => _AlbumSearcheable(a))); - _log.info("[_onEventPreloadData] Loaded ${albums.length} albums"); + if (collections.isEmpty) { + collections = await ListCollection(_c)(account).last; + } + product.addAll(collections.map(_CollectionSearcheable.new)); + _log.info( + "[_onEventPreloadData] Loaded ${collections.length} collections"); } catch (e) { - _log.warning("[_onEventPreloadData] Failed while ListAlbum", e); + _log.warning("[_onEventPreloadData] Failed while ListCollection", e); } try { final tags = await ListTag(_c)(account); @@ -239,6 +246,7 @@ class HomeSearchSuggestionBloc } final Account account; + final CollectionsController collectionsController; late final DiContainer _c; final _search = Woozy<_Searcheable>(limit: 10); @@ -249,16 +257,16 @@ abstract class _Searcheable { HomeSearchResult toResult(); } -class _AlbumSearcheable implements _Searcheable { - const _AlbumSearcheable(this.album); +class _CollectionSearcheable implements _Searcheable { + const _CollectionSearcheable(this.collection); @override - toKeywords() => [album.name.toCi()]; + toKeywords() => [collection.name.toCi()]; @override - toResult() => HomeSearchAlbumResult(album); + toResult() => HomeSearchCollectionResult(collection); - final Album album; + final Collection collection; } class _TagSearcheable implements _Searcheable { diff --git a/app/lib/bloc/home_search_suggestion.g.dart b/app/lib/bloc/home_search_suggestion.g.dart index 34582ebd..7f60b4cd 100644 --- a/app/lib/bloc/home_search_suggestion.g.dart +++ b/app/lib/bloc/home_search_suggestion.g.dart @@ -18,10 +18,10 @@ extension _$HomeSearchSuggestionBlocNpLog on HomeSearchSuggestionBloc { // ToStringGenerator // ************************************************************************** -extension _$HomeSearchAlbumResultToString on HomeSearchAlbumResult { +extension _$HomeSearchCollectionResultToString on HomeSearchCollectionResult { String _$toString() { // ignore: unnecessary_string_interpolations - return "HomeSearchAlbumResult {album: $album}"; + return "HomeSearchCollectionResult {collection: $collection}"; } } diff --git a/app/lib/bloc/list_album.dart b/app/lib/bloc/list_album.dart deleted file mode 100644 index 7aa3c79c..00000000 --- a/app/lib/bloc/list_album.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/share.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/exception.dart'; -import 'package:nc_photos/exception_event.dart'; -import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/use_case/list_album.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'list_album.g.dart'; - -class ListAlbumBlocItem { - ListAlbumBlocItem(this.album); - - final Album album; -} - -abstract class ListAlbumBlocEvent { - const ListAlbumBlocEvent(); -} - -@toString -class ListAlbumBlocQuery extends ListAlbumBlocEvent { - const ListAlbumBlocQuery(this.account); - - @override - String toString() => _$toString(); - - final Account account; -} - -/// An external event has happened and may affect the state of this bloc -@toString -class _ListAlbumBlocExternalEvent extends ListAlbumBlocEvent { - const _ListAlbumBlocExternalEvent(); - - @override - String toString() => _$toString(); -} - -@toString -abstract class ListAlbumBlocState { - const ListAlbumBlocState(this.account, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final List items; -} - -class ListAlbumBlocInit extends ListAlbumBlocState { - const ListAlbumBlocInit() : super(null, const []); -} - -class ListAlbumBlocLoading extends ListAlbumBlocState { - const ListAlbumBlocLoading(Account? account, List items) - : super(account, items); -} - -class ListAlbumBlocSuccess extends ListAlbumBlocState { - const ListAlbumBlocSuccess(Account? account, List items) - : super(account, items); -} - -@toString -class ListAlbumBlocFailure extends ListAlbumBlocState { - const ListAlbumBlocFailure( - Account? account, List items, this.exception) - : super(account, items); - - @override - String toString() => _$toString(); - - final dynamic exception; -} - -/// The state of this bloc is inconsistent. This typically means that the data -/// may have been changed externally -class ListAlbumBlocInconsistent extends ListAlbumBlocState { - const ListAlbumBlocInconsistent( - Account? account, List items) - : super(account, items); -} - -@npLog -class ListAlbumBloc extends Bloc { - /// Constructor - /// - /// If [offlineC] is not null, this [DiContainer] will be used when requesting - /// offline contents, otherwise [_c] will be used - ListAlbumBloc( - this._c, [ - DiContainer? offlineC, - ]) : _offlineC = offlineC ?? _c, - assert(require(_c)), - assert(offlineC == null || require(offlineC)), - assert(ListAlbum.require(_c)), - assert(offlineC == null || ListAlbum.require(offlineC)), - super(const ListAlbumBlocInit()) { - _albumUpdatedListener = - AppEventListener(_onAlbumUpdatedEvent); - _fileRemovedListener = - AppEventListener(_onFileRemovedEvent); - _albumCreatedListener = - AppEventListener(_onAlbumCreatedEvent); - _albumUpdatedListener.begin(); - _fileRemovedListener.begin(); - _albumCreatedListener.begin(); - _fileMovedListener.begin(); - _shareCreatedListener.begin(); - _shareRemovedListener.begin(); - - _refreshThrottler = Throttler( - onTriggered: (_) { - add(const _ListAlbumBlocExternalEvent()); - }, - logTag: "ListAlbumBloc.refresh", - ); - - on(_onEvent); - } - - static bool require(DiContainer c) => true; - - static ListAlbumBloc of(Account account) { - final name = bloc_util.getInstNameForAccount("ListAlbumBloc", account); - try { - _log.fine("[of] Resolving bloc for '$name'"); - return KiwiContainer().resolve(name); - } catch (_) { - // no created instance for this account, make a new one - _log.info("[of] New bloc instance for account: $account"); - final c = KiwiContainer().resolve(); - final offlineC = c.copyWith( - fileRepo: OrNull(c.fileRepoLocal), - albumRepo: OrNull(c.albumRepoLocal), - ); - final bloc = ListAlbumBloc(c, offlineC); - KiwiContainer().registerInstance(bloc, name: name); - return bloc; - } - } - - @override - close() { - _albumUpdatedListener.end(); - _fileRemovedListener.end(); - _albumCreatedListener.end(); - _fileMovedListener.end(); - _shareCreatedListener.end(); - _shareRemovedListener.end(); - _refreshThrottler.clear(); - return super.close(); - } - - Future _onEvent( - ListAlbumBlocEvent event, Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListAlbumBlocQuery) { - await _onEventQuery(event, emit); - } else if (event is _ListAlbumBlocExternalEvent) { - await _onExternalEvent(event, emit); - } - } - - Future _onEventQuery( - ListAlbumBlocQuery ev, Emitter emit) async { - emit(ListAlbumBlocLoading(ev.account, state.items)); - bool hasContent = state.items.isNotEmpty; - - if (!hasContent) { - // show something instantly on first load - final cacheState = await _queryOffline(ev); - emit(ListAlbumBlocLoading(ev.account, cacheState.items)); - hasContent = cacheState.items.isNotEmpty; - } - - final newState = await _queryOnline(ev); - if (newState is ListAlbumBlocFailure) { - emit(ListAlbumBlocFailure( - ev.account, - newState.items.isNotEmpty ? newState.items : state.items, - newState.exception)); - } else { - emit(newState); - } - } - - Future _onExternalEvent( - _ListAlbumBlocExternalEvent ev, Emitter emit) async { - emit(ListAlbumBlocInconsistent(state.account, state.items)); - } - - void _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) { - if (state is ListAlbumBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isAccountOfInterest(ev.account)) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - - void _onFileRemovedEvent(FileRemovedEvent ev) { - if (state is ListAlbumBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isAccountOfInterest(ev.account) && - file_util.isAlbumFile(ev.account, ev.file)) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - - void _onFileMovedEvent(FileMovedEvent ev) { - if (state is ListAlbumBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isAccountOfInterest(ev.account)) { - if (ev.destination - .startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account)) || - ev.file.path - .startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account))) { - // moving from/to album dir - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - } - - void _onAlbumCreatedEvent(AlbumCreatedEvent ev) { - if (state is ListAlbumBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isAccountOfInterest(ev.account)) { - add(const _ListAlbumBlocExternalEvent()); - } - } - - void _onShareCreatedEvent(ShareCreatedEvent ev) => - _onShareChanged(ev.account, ev.share); - - void _onShareRemovedEvent(ShareRemovedEvent ev) => - _onShareChanged(ev.account, ev.share); - - void _onShareChanged(Account account, Share share) { - if (_isAccountOfInterest(account)) { - final webdavPath = file_util.unstripPath(account, share.path); - if (webdavPath - .startsWith(remote_storage_util.getRemoteAlbumsDir(account))) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - } - - Future _queryOffline(ListAlbumBlocQuery ev) => - _queryWithAlbumDataSource(_offlineC, ev); - - Future _queryOnline(ListAlbumBlocQuery ev) => - _queryWithAlbumDataSource(_c, ev); - - Future _queryWithAlbumDataSource( - DiContainer c, ListAlbumBlocQuery ev) async { - try { - final albums = []; - final errors = []; - await for (final result in ListAlbum(c)(ev.account)) { - if (result is ExceptionEvent) { - if (result.error is CacheNotFoundException) { - _log.info( - "[_queryWithAlbumDataSource] Cache not found", result.error); - } else { - _log.shout("[_queryWithAlbumDataSource] Exception while ListAlbum", - result.error, result.stackTrace); - } - errors.add(result.error); - } else if (result is Album) { - albums.add(result); - } - } - - final items = albums.map((e) => ListAlbumBlocItem(e)).toList(); - if (errors.isEmpty) { - return ListAlbumBlocSuccess(ev.account, items); - } else { - return ListAlbumBlocFailure(ev.account, items, errors.first); - } - } catch (e, stacktrace) { - _log.severe("[_queryWithAlbumDataSource] Exception", e, stacktrace); - return ListAlbumBlocFailure(ev.account, [], e); - } - } - - bool _isAccountOfInterest(Account account) => - state.account == null || state.account!.compareServerIdentity(account); - - final DiContainer _c; - final DiContainer _offlineC; - - late AppEventListener _albumUpdatedListener; - late AppEventListener _fileRemovedListener; - late AppEventListener _albumCreatedListener; - late final _fileMovedListener = - AppEventListener(_onFileMovedEvent); - late final _shareCreatedListener = - AppEventListener(_onShareCreatedEvent); - late final _shareRemovedListener = - AppEventListener(_onShareRemovedEvent); - - late Throttler _refreshThrottler; - - static final _log = _$ListAlbumBlocNpLog.log; -} diff --git a/app/lib/bloc/list_album.g.dart b/app/lib/bloc/list_album.g.dart deleted file mode 100644 index 12ed89b0..00000000 --- a/app/lib/bloc/list_album.g.dart +++ /dev/null @@ -1,46 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_album.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListAlbumBlocNpLog on ListAlbumBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_album.ListAlbumBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListAlbumBlocQueryToString on ListAlbumBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListAlbumBlocQuery {account: $account}"; - } -} - -extension _$_ListAlbumBlocExternalEventToString on _ListAlbumBlocExternalEvent { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "_ListAlbumBlocExternalEvent {}"; - } -} - -extension _$ListAlbumBlocStateToString on ListAlbumBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListAlbumBlocState")} {account: $account, items: [length: ${items.length}]}"; - } -} - -extension _$ListAlbumBlocFailureToString on ListAlbumBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListAlbumBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/bloc/list_face_file.dart b/app/lib/bloc/list_face_file.dart deleted file mode 100644 index e833901a..00000000 --- a/app/lib/bloc/list_face_file.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/person.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/use_case/populate_person.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'list_face_file.g.dart'; - -abstract class ListFaceFileBlocEvent { - const ListFaceFileBlocEvent(); -} - -@toString -class ListFaceFileBlocQuery extends ListFaceFileBlocEvent { - const ListFaceFileBlocQuery(this.account, this.person); - - @override - String toString() => _$toString(); - - final Account account; - final Person person; -} - -/// An external event has happened and may affect the state of this bloc -@toString -class _ListFaceFileBlocExternalEvent extends ListFaceFileBlocEvent { - const _ListFaceFileBlocExternalEvent(); - - @override - String toString() => _$toString(); -} - -@toString -abstract class ListFaceFileBlocState { - const ListFaceFileBlocState(this.account, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final List items; -} - -class ListFaceFileBlocInit extends ListFaceFileBlocState { - ListFaceFileBlocInit() : super(null, const []); -} - -class ListFaceFileBlocLoading extends ListFaceFileBlocState { - const ListFaceFileBlocLoading(Account? account, List items) - : super(account, items); -} - -class ListFaceFileBlocSuccess extends ListFaceFileBlocState { - const ListFaceFileBlocSuccess(Account? account, List items) - : super(account, items); -} - -@toString -class ListFaceFileBlocFailure extends ListFaceFileBlocState { - const ListFaceFileBlocFailure( - Account? account, List items, this.exception) - : super(account, items); - - @override - String toString() => _$toString(); - - final Object exception; -} - -/// The state of this bloc is inconsistent. This typically means that the data -/// may have been changed externally -class ListFaceFileBlocInconsistent extends ListFaceFileBlocState { - const ListFaceFileBlocInconsistent(Account? account, List items) - : super(account, items); -} - -/// List all people recognized in an account -@npLog -class ListFaceFileBloc - extends Bloc { - ListFaceFileBloc(this._c) - : assert(require(_c)), - assert(PopulatePerson.require(_c)), - super(ListFaceFileBlocInit()) { - _fileRemovedEventListener.begin(); - _filePropertyUpdatedEventListener.begin(); - - on(_onEvent); - } - - static bool require(DiContainer c) => DiContainer.has(c, DiType.faceRepo); - - @override - close() { - _fileRemovedEventListener.end(); - _filePropertyUpdatedEventListener.end(); - return super.close(); - } - - Future _onEvent( - ListFaceFileBlocEvent event, Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListFaceFileBlocQuery) { - await _onEventQuery(event, emit); - } else if (event is _ListFaceFileBlocExternalEvent) { - await _onExternalEvent(event, emit); - } - } - - Future _onEventQuery( - ListFaceFileBlocQuery ev, Emitter emit) async { - try { - emit(ListFaceFileBlocLoading(ev.account, state.items)); - emit(ListFaceFileBlocSuccess(ev.account, await _query(ev))); - } catch (e, stackTrace) { - _log.severe("[_onEventQuery] Exception while request", e, stackTrace); - emit(ListFaceFileBlocFailure(ev.account, state.items, e)); - } - } - - Future _onExternalEvent(_ListFaceFileBlocExternalEvent ev, - Emitter emit) async { - emit(ListFaceFileBlocInconsistent(state.account, state.items)); - } - - void _onFileRemovedEvent(FileRemovedEvent ev) { - if (state is ListFaceFileBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isFileOfInterest(ev.file)) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - - void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) { - if (!ev.hasAnyProperties([ - FilePropertyUpdatedEvent.propMetadata, - FilePropertyUpdatedEvent.propIsArchived, - FilePropertyUpdatedEvent.propOverrideDateTime, - FilePropertyUpdatedEvent.propFavorite, - ])) { - // not interested - return; - } - if (state is ListFaceFileBlocInit) { - // no data in this bloc, ignore - return; - } - if (!_isFileOfInterest(ev.file)) { - return; - } - - if (ev.hasAnyProperties([ - FilePropertyUpdatedEvent.propIsArchived, - FilePropertyUpdatedEvent.propOverrideDateTime, - FilePropertyUpdatedEvent.propFavorite, - ])) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } else { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 10), - maxPendingCount: 10, - ); - } - } - - Future> _query(ListFaceFileBlocQuery ev) async { - final faces = await _c.faceRepo.list(ev.account, ev.person); - final files = await PopulatePerson(_c)(ev.account, faces); - final rootDirs = ev.account.roots - .map((e) => File(path: file_util.unstripPath(ev.account, e))) - .toList(); - return files - .where((f) => - file_util.isSupportedFormat(f) && - rootDirs.any((dir) => file_util.isUnderDir(f, dir))) - .toList(); - } - - bool _isFileOfInterest(File file) { - if (!file_util.isSupportedFormat(file)) { - return false; - } - - for (final r in state.account?.roots ?? []) { - final dir = File(path: file_util.unstripPath(state.account!, r)); - if (file_util.isUnderDir(file, dir)) { - return true; - } - } - return false; - } - - final DiContainer _c; - - late final _fileRemovedEventListener = - AppEventListener(_onFileRemovedEvent); - late final _filePropertyUpdatedEventListener = - AppEventListener(_onFilePropertyUpdatedEvent); - - late final _refreshThrottler = Throttler( - onTriggered: (_) { - add(const _ListFaceFileBlocExternalEvent()); - }, - logTag: "ListFaceFileBloc.refresh", - ); -} diff --git a/app/lib/bloc/list_face_file.g.dart b/app/lib/bloc/list_face_file.g.dart deleted file mode 100644 index c4dc5109..00000000 --- a/app/lib/bloc/list_face_file.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_face_file.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListFaceFileBlocNpLog on ListFaceFileBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_face_file.ListFaceFileBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListFaceFileBlocQueryToString on ListFaceFileBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListFaceFileBlocQuery {account: $account, person: $person}"; - } -} - -extension _$_ListFaceFileBlocExternalEventToString - on _ListFaceFileBlocExternalEvent { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "_ListFaceFileBlocExternalEvent {}"; - } -} - -extension _$ListFaceFileBlocStateToString on ListFaceFileBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListFaceFileBlocState")} {account: $account, items: [length: ${items.length}]}"; - } -} - -extension _$ListFaceFileBlocFailureToString on ListFaceFileBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListFaceFileBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/bloc/list_importable_album.dart b/app/lib/bloc/list_importable_album.dart index 20b38793..2f1c3bce 100644 --- a/app/lib/bloc/list_importable_album.dart +++ b/app/lib/bloc/list_importable_album.dart @@ -10,7 +10,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; -import 'package:nc_photos/use_case/list_album.dart'; +import 'package:nc_photos/use_case/album/list_album.dart'; import 'package:nc_photos/use_case/ls.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:to_string/to_string.dart'; diff --git a/app/lib/bloc/list_location.dart b/app/lib/bloc/list_location.dart index aa950f46..a46d3a00 100644 --- a/app/lib/bloc/list_location.dart +++ b/app/lib/bloc/list_location.dart @@ -4,6 +4,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/throttler.dart'; @@ -147,7 +148,7 @@ class ListLocationBloc Future _query(ListLocationBlocQuery ev) => ListLocationGroup(_c.withLocalRepo())(ev.account); - bool _isFileOfInterest(File file) { + bool _isFileOfInterest(FileDescriptor file) { if (!file_util.isSupportedFormat(file)) { return false; } diff --git a/app/lib/bloc/list_location_file.dart b/app/lib/bloc/list_location_file.dart deleted file mode 100644 index cbc01d9f..00000000 --- a/app/lib/bloc/list_location_file.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/use_case/list_location_file.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'list_location_file.g.dart'; - -abstract class ListLocationFileBlocEvent { - const ListLocationFileBlocEvent(); -} - -@toString -class ListLocationFileBlocQuery extends ListLocationFileBlocEvent { - const ListLocationFileBlocQuery(this.account, this.place, this.countryCode); - - @override - String toString() => _$toString(); - - final Account account; - final String? place; - final String countryCode; -} - -/// An external event has happened and may affect the state of this bloc -@toString -class _ListLocationFileBlocExternalEvent extends ListLocationFileBlocEvent { - const _ListLocationFileBlocExternalEvent(); - - @override - String toString() => _$toString(); -} - -@toString -abstract class ListLocationFileBlocState { - const ListLocationFileBlocState(this.account, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final List items; -} - -class ListLocationFileBlocInit extends ListLocationFileBlocState { - ListLocationFileBlocInit() : super(null, const []); -} - -class ListLocationFileBlocLoading extends ListLocationFileBlocState { - const ListLocationFileBlocLoading(Account? account, List items) - : super(account, items); -} - -class ListLocationFileBlocSuccess extends ListLocationFileBlocState { - const ListLocationFileBlocSuccess(Account? account, List items) - : super(account, items); -} - -@toString -class ListLocationFileBlocFailure extends ListLocationFileBlocState { - const ListLocationFileBlocFailure( - Account? account, List items, this.exception) - : super(account, items); - - @override - String toString() => _$toString(); - - final Object exception; -} - -/// The state of this bloc is inconsistent. This typically means that the data -/// may have been changed externally -class ListLocationFileBlocInconsistent extends ListLocationFileBlocState { - const ListLocationFileBlocInconsistent(Account? account, List items) - : super(account, items); -} - -/// List all files associated with a specific tag -@npLog -class ListLocationFileBloc - extends Bloc { - ListLocationFileBloc(this._c) - : assert(require(_c)), - assert(ListLocationFile.require(_c)), - super(ListLocationFileBlocInit()) { - _fileRemovedEventListener.begin(); - - on(_onEvent); - } - - static bool require(DiContainer c) => - DiContainer.has(c, DiType.taggedFileRepo); - - @override - close() { - _fileRemovedEventListener.end(); - return super.close(); - } - - Future _onEvent(ListLocationFileBlocEvent event, - Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListLocationFileBlocQuery) { - await _onEventQuery(event, emit); - } else if (event is _ListLocationFileBlocExternalEvent) { - await _onExternalEvent(event, emit); - } - } - - Future _onEventQuery(ListLocationFileBlocQuery ev, - Emitter emit) async { - try { - emit(ListLocationFileBlocLoading(ev.account, state.items)); - emit(ListLocationFileBlocSuccess(ev.account, await _query(ev))); - } catch (e, stackTrace) { - _log.severe("[_onEventQuery] Exception while request", e, stackTrace); - emit(ListLocationFileBlocFailure(ev.account, state.items, e)); - } - } - - Future _onExternalEvent(_ListLocationFileBlocExternalEvent ev, - Emitter emit) async { - emit(ListLocationFileBlocInconsistent(state.account, state.items)); - } - - void _onFileRemovedEvent(FileRemovedEvent ev) { - if (state is ListLocationFileBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isFileOfInterest(ev.file)) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - - Future> _query(ListLocationFileBlocQuery ev) async { - final files = []; - for (final r in ev.account.roots) { - final dir = File(path: file_util.unstripPath(ev.account, r)); - files.addAll(await ListLocationFile(_c)( - ev.account, dir, ev.place, ev.countryCode)); - } - return files.where((f) => file_util.isSupportedFormat(f)).toList(); - } - - bool _isFileOfInterest(File file) { - if (!file_util.isSupportedFormat(file)) { - return false; - } - - for (final r in state.account?.roots ?? []) { - final dir = File(path: file_util.unstripPath(state.account!, r)); - if (file_util.isUnderDir(file, dir)) { - return true; - } - } - return false; - } - - final DiContainer _c; - - late final _fileRemovedEventListener = - AppEventListener(_onFileRemovedEvent); - - late final _refreshThrottler = Throttler( - onTriggered: (_) { - add(const _ListLocationFileBlocExternalEvent()); - }, - logTag: "ListLocationFileBloc.refresh", - ); -} diff --git a/app/lib/bloc/list_location_file.g.dart b/app/lib/bloc/list_location_file.g.dart deleted file mode 100644 index 0010b54e..00000000 --- a/app/lib/bloc/list_location_file.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_location_file.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListLocationFileBlocNpLog on ListLocationFileBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_location_file.ListLocationFileBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListLocationFileBlocQueryToString on ListLocationFileBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListLocationFileBlocQuery {account: $account, place: $place, countryCode: $countryCode}"; - } -} - -extension _$_ListLocationFileBlocExternalEventToString - on _ListLocationFileBlocExternalEvent { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "_ListLocationFileBlocExternalEvent {}"; - } -} - -extension _$ListLocationFileBlocStateToString on ListLocationFileBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListLocationFileBlocState")} {account: $account, items: [length: ${items.length}]}"; - } -} - -extension _$ListLocationFileBlocFailureToString on ListLocationFileBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListLocationFileBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/bloc/list_tag_file.dart b/app/lib/bloc/list_tag_file.dart deleted file mode 100644 index 7a346d9d..00000000 --- a/app/lib/bloc/list_tag_file.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/tag.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/use_case/find_file.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'list_tag_file.g.dart'; - -abstract class ListTagFileBlocEvent { - const ListTagFileBlocEvent(); -} - -@toString -class ListTagFileBlocQuery extends ListTagFileBlocEvent { - const ListTagFileBlocQuery(this.account, this.tag); - - @override - String toString() => _$toString(); - - final Account account; - final Tag tag; -} - -/// An external event has happened and may affect the state of this bloc -@toString -class _ListTagFileBlocExternalEvent extends ListTagFileBlocEvent { - const _ListTagFileBlocExternalEvent(); - - @override - String toString() => _$toString(); -} - -@toString -abstract class ListTagFileBlocState { - const ListTagFileBlocState(this.account, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final List items; -} - -class ListTagFileBlocInit extends ListTagFileBlocState { - ListTagFileBlocInit() : super(null, const []); -} - -class ListTagFileBlocLoading extends ListTagFileBlocState { - const ListTagFileBlocLoading(Account? account, List items) - : super(account, items); -} - -class ListTagFileBlocSuccess extends ListTagFileBlocState { - const ListTagFileBlocSuccess(Account? account, List items) - : super(account, items); -} - -@toString -class ListTagFileBlocFailure extends ListTagFileBlocState { - const ListTagFileBlocFailure( - Account? account, List items, this.exception) - : super(account, items); - - @override - String toString() => _$toString(); - - final Object exception; -} - -/// The state of this bloc is inconsistent. This typically means that the data -/// may have been changed externally -class ListTagFileBlocInconsistent extends ListTagFileBlocState { - const ListTagFileBlocInconsistent(Account? account, List items) - : super(account, items); -} - -/// List all files associated with a specific tag -@npLog -class ListTagFileBloc extends Bloc { - ListTagFileBloc(this._c) - : assert(require(_c)), - // assert(PopulatePerson.require(_c)), - super(ListTagFileBlocInit()) { - _fileRemovedEventListener.begin(); - _filePropertyUpdatedEventListener.begin(); - - on(_onEvent); - } - - static bool require(DiContainer c) => - DiContainer.has(c, DiType.taggedFileRepo); - - @override - close() { - _fileRemovedEventListener.end(); - _filePropertyUpdatedEventListener.end(); - return super.close(); - } - - Future _onEvent( - ListTagFileBlocEvent event, Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListTagFileBlocQuery) { - await _onEventQuery(event, emit); - } else if (event is _ListTagFileBlocExternalEvent) { - await _onExternalEvent(event, emit); - } - } - - Future _onEventQuery( - ListTagFileBlocQuery ev, Emitter emit) async { - try { - emit(ListTagFileBlocLoading(ev.account, state.items)); - emit(ListTagFileBlocSuccess(ev.account, await _query(ev))); - } catch (e, stackTrace) { - _log.severe("[_onEventQuery] Exception while request", e, stackTrace); - emit(ListTagFileBlocFailure(ev.account, state.items, e)); - } - } - - Future _onExternalEvent(_ListTagFileBlocExternalEvent ev, - Emitter emit) async { - emit(ListTagFileBlocInconsistent(state.account, state.items)); - } - - void _onFileRemovedEvent(FileRemovedEvent ev) { - if (state is ListTagFileBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isFileOfInterest(ev.file)) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - } - - void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) { - if (!ev.hasAnyProperties([ - FilePropertyUpdatedEvent.propMetadata, - FilePropertyUpdatedEvent.propIsArchived, - FilePropertyUpdatedEvent.propOverrideDateTime, - FilePropertyUpdatedEvent.propFavorite, - ])) { - // not interested - return; - } - if (state is ListTagFileBlocInit) { - // no data in this bloc, ignore - return; - } - if (!_isFileOfInterest(ev.file)) { - return; - } - - if (ev.hasAnyProperties([ - FilePropertyUpdatedEvent.propIsArchived, - FilePropertyUpdatedEvent.propOverrideDateTime, - FilePropertyUpdatedEvent.propFavorite, - ])) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } else { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 10), - maxPendingCount: 10, - ); - } - } - - Future> _query(ListTagFileBlocQuery ev) async { - final files = []; - for (final r in ev.account.roots) { - final dir = File(path: file_util.unstripPath(ev.account, r)); - final taggedFiles = - await _c.taggedFileRepo.list(ev.account, dir, [ev.tag]); - files.addAll(await FindFile(_c)( - ev.account, - taggedFiles.map((e) => e.fileId).toList(), - onFileNotFound: (id) { - _log.warning("[_query] Missing file: $id"); - }, - )); - } - return files.where((f) => file_util.isSupportedFormat(f)).toList(); - } - - bool _isFileOfInterest(File file) { - if (!file_util.isSupportedFormat(file)) { - return false; - } - - for (final r in state.account?.roots ?? []) { - final dir = File(path: file_util.unstripPath(state.account!, r)); - if (file_util.isUnderDir(file, dir)) { - return true; - } - } - return false; - } - - final DiContainer _c; - - late final _fileRemovedEventListener = - AppEventListener(_onFileRemovedEvent); - late final _filePropertyUpdatedEventListener = - AppEventListener(_onFilePropertyUpdatedEvent); - - late final _refreshThrottler = Throttler( - onTriggered: (_) { - add(const _ListTagFileBlocExternalEvent()); - }, - logTag: "ListTagFileBloc.refresh", - ); -} diff --git a/app/lib/bloc/list_tag_file.g.dart b/app/lib/bloc/list_tag_file.g.dart deleted file mode 100644 index 2ba329b9..00000000 --- a/app/lib/bloc/list_tag_file.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_tag_file.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListTagFileBlocNpLog on ListTagFileBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_tag_file.ListTagFileBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListTagFileBlocQueryToString on ListTagFileBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListTagFileBlocQuery {account: $account, tag: $tag}"; - } -} - -extension _$_ListTagFileBlocExternalEventToString - on _ListTagFileBlocExternalEvent { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "_ListTagFileBlocExternalEvent {}"; - } -} - -extension _$ListTagFileBlocStateToString on ListTagFileBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListTagFileBlocState")} {account: $account, items: [length: ${items.length}]}"; - } -} - -extension _$ListTagFileBlocFailureToString on ListTagFileBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListTagFileBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/bloc/scan_account_dir.dart b/app/lib/bloc/scan_account_dir.dart index bd1365f5..cfc3aebe 100644 --- a/app/lib/bloc/scan_account_dir.dart +++ b/app/lib/bloc/scan_account_dir.dart @@ -481,7 +481,7 @@ class ScanAccountDirBloc return files; } - bool _isFileOfInterest(File file) { + bool _isFileOfInterest(FileDescriptor file) { if (!file_util.isSupportedFormat(file)) { return false; } diff --git a/app/lib/bloc/search.dart b/app/lib/bloc/search.dart index d11ae8da..c0a745c4 100644 --- a/app/lib/bloc/search.dart +++ b/app/lib/bloc/search.dart @@ -4,6 +4,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/search.dart'; import 'package:nc_photos/event/event.dart'; @@ -198,7 +199,7 @@ class SearchBloc extends Bloc { Future> _query(SearchBlocQuery ev) => Search(_c)(ev.account, ev.criteria); - bool _isFileOfInterest(File file) { + bool _isFileOfInterest(FileDescriptor file) { if (!file_util.isSupportedFormat(file)) { return false; } diff --git a/app/lib/bloc_util.dart b/app/lib/bloc_util.dart new file mode 100644 index 00000000..2d9d77e7 --- /dev/null +++ b/app/lib/bloc_util.dart @@ -0,0 +1,3 @@ +abstract class BlocTag { + String get tag; +} diff --git a/app/lib/controller/account_controller.dart b/app/lib/controller/account_controller.dart new file mode 100644 index 00000000..bad7aa10 --- /dev/null +++ b/app/lib/controller/account_controller.dart @@ -0,0 +1,23 @@ +import 'package:kiwi/kiwi.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/di_container.dart'; + +class AccountController { + void setCurrentAccount(Account account) { + _account = account; + _collectionsController?.dispose(); + _collectionsController = null; + } + + Account get account => _account!; + + CollectionsController get collectionsController => + _collectionsController ??= CollectionsController( + KiwiContainer().resolve(), + account: _account!, + ); + + Account? _account; + CollectionsController? _collectionsController; +} diff --git a/app/lib/controller/collection_items_controller.dart b/app/lib/controller/collection_items_controller.dart new file mode 100644 index 00000000..8fa0d569 --- /dev/null +++ b/app/lib/controller/collection_items_controller.dart @@ -0,0 +1,336 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:mutex/mutex.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/new_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/list_extension.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/rx_extension.dart'; +import 'package:nc_photos/use_case/collection/add_file_to_collection.dart'; +import 'package:nc_photos/use_case/collection/list_collection_item.dart'; +import 'package:nc_photos/use_case/collection/remove_from_collection.dart'; +import 'package:nc_photos/use_case/remove.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'collection_items_controller.g.dart'; + +@genCopyWith +class CollectionItemStreamData { + const CollectionItemStreamData({ + required this.items, + required this.hasNext, + }); + + final List items; + + /// If true, the results are intermediate values and may not represent the + /// latest state + final bool hasNext; +} + +@npLog +class CollectionItemsController { + CollectionItemsController( + this._c, { + required this.account, + required this.collection, + required this.onCollectionUpdated, + }) { + _fileRemovedEventListener.begin(); + } + + /// Dispose this controller and release all internal resources + /// + /// MUST be called + void dispose() { + _fileRemovedEventListener.end(); + _dataStreamController.close(); + } + + /// Subscribe to collection items in [collection] + /// + /// The returned stream will emit new list of items whenever there are changes + /// to the items (e.g., new item, removed item, etc) + /// + /// There's no guarantee that the returned list is always sorted in some ways, + /// callers must sort it by themselves if the ordering is important + ValueStream get stream { + if (!_isDataStreamInited) { + _isDataStreamInited = true; + unawaited(_load()); + } + return _dataStreamController.stream; + } + + /// Add list of [files] to [collection] + Future addFiles(List files) async { + final isInited = _isDataStreamInited; + final List toAdd; + if (isInited) { + toAdd = files + .where((a) => _dataStreamController.value.items + .whereType() + .every((b) => !a.compareServerIdentity(b.file))) + .toList(); + _log.info("[addFiles] Adding ${toAdd.length} non duplicated files"); + if (toAdd.isEmpty) { + return; + } + _dataStreamController.addWithValue((value) => value.copyWith( + items: [ + ...toAdd.map((f) => NewCollectionFileItem(f)), + ...value.items, + ], + )); + } else { + toAdd = files; + _log.info("[addFiles] Adding ${toAdd.length} files"); + if (toAdd.isEmpty) { + return; + } + } + ExceptionEvent? error; + + final failed = []; + await _mutex.protect(() async { + await AddFileToCollection(_c)( + account, + collection, + toAdd, + onError: (f, e, stackTrace) { + _log.severe("[addFiles] Exception: ${logFilename(f.strippedPath)}", e, + stackTrace); + error ??= ExceptionEvent(e, stackTrace); + failed.add(f); + }, + onCollectionUpdated: (value) { + collection = value; + onCollectionUpdated(collection); + }, + ); + + if (isInited) { + error + ?.run((e) => _dataStreamController.addError(e.error, e.stackTrace)); + var finalize = _dataStreamController.value.items.toList(); + if (failed.isNotEmpty) { + // remove failed items + finalize.removeWhere((r) { + if (r is CollectionFileItem) { + return failed.any((f) => r.file.compareServerIdentity(f)); + } else { + return false; + } + }); + } + // convert intermediate items + finalize = (await finalize.asyncMap((e) async { + try { + if (e is NewCollectionFileItem) { + return await CollectionAdapter.of(_c, account, collection) + .adaptToNewItem(e); + } else { + return e; + } + } catch (e, stackTrace) { + _log.severe("[addFiles] Item not found in resulting collection: $e", + e, stackTrace); + return null; + } + })) + .whereNotNull() + .toList(); + _dataStreamController.addWithValue((value) => value.copyWith( + items: finalize, + )); + } else if (isInited != _isDataStreamInited) { + // stream loaded in between this op, reload + unawaited(_load()); + } + }); + } + + /// Remove list of [items] from [collection] + /// + /// The items are compared with [identical], so it's required that all the + /// item instances come from the value stream + Future removeItems(List items) async { + final isInited = _isDataStreamInited; + if (isInited) { + _dataStreamController.addWithValue((value) => value.copyWith( + items: value.items + .where((a) => !items.any((b) => identical(a, b))) + .toList(), + )); + } + ExceptionEvent? error; + + final failed = []; + await _mutex.protect(() async { + await RemoveFromCollection(_c)( + account, + collection, + items, + onError: (_, item, e, stackTrace) { + _log.severe("[removeItems] Exception: $item", e, stackTrace); + error ??= ExceptionEvent(e, stackTrace); + failed.add(item); + }, + onCollectionUpdated: (value) { + collection = value; + onCollectionUpdated(collection); + }, + ); + + if (isInited) { + error + ?.run((e) => _dataStreamController.addError(e.error, e.stackTrace)); + if (failed.isNotEmpty) { + _dataStreamController.addWithValue((value) => value.copyWith( + items: value.items + failed, + )); + } + } else if (isInited != _isDataStreamInited) { + // stream loaded in between this op, reload + unawaited(_load()); + } + }); + } + + /// Delete list of [files] from your server + /// + /// This is a temporary workaround and will be moved away + Future deleteItems(List files) async { + final isInited = _isDataStreamInited; + final List toDelete; + List? toDeleteItem; + if (isInited) { + final groups = _dataStreamController.value.items.groupListsBy((i) { + if (i is CollectionFileItem) { + return !files.any((f) => i.file.compareServerIdentity(f)); + } else { + return true; + } + }); + final retain = groups[true] ?? const []; + toDeleteItem = groups[false]?.cast() ?? const []; + if (toDeleteItem.isEmpty) { + return; + } + _dataStreamController.addWithValue((value) => value.copyWith( + items: retain, + )); + toDelete = toDeleteItem.map((e) => e.file).toList(); + } else { + toDelete = files; + } + ExceptionEvent? error; + + final failed = []; + await _mutex.protect(() async { + await Remove(_c)( + account, + toDelete, + onError: (i, f, e, stackTrace) { + _log.severe("[deleteItems] Exception: ${logFilename(f.strippedPath)}", + e, stackTrace); + error ??= ExceptionEvent(e, stackTrace); + if (isInited) { + failed.add(toDeleteItem![i]); + } + }, + ); + + if (isInited) { + error + ?.run((e) => _dataStreamController.addError(e.error, e.stackTrace)); + if (failed.isNotEmpty) { + _dataStreamController.addWithValue((value) => value.copyWith( + items: value.items + failed, + )); + } + } else if (isInited != _isDataStreamInited) { + // stream loaded in between this op, reload + unawaited(_load()); + } + }); + } + + /// Replace items in the stream, for internal use only + void forceReplaceItems(List items) { + _dataStreamController.addWithValue((v) => v.copyWith(items: items)); + } + + Future _load() async { + try { + List? items; + await for (final r in ListCollectionItem(_c)(account, collection)) { + items = r; + _dataStreamController.add(CollectionItemStreamData( + items: r, + hasNext: true, + )); + } + if (items != null) { + _dataStreamController.add(CollectionItemStreamData( + items: items, + hasNext: false, + )); + } + } catch (e, stackTrace) { + _dataStreamController + ..addError(e, stackTrace) + ..addWithValue((v) => v.copyWith(hasNext: false)); + } + } + + void _onFileRemovedEvent(FileRemovedEvent ev) { + // if (account != ev.account) { + // return; + // } + // final newItems = _dataStreamController.value.items.where((e) { + // if (e is CollectionFileItem) { + // return !e.file.compareServerIdentity(ev.file); + // } else { + // return true; + // } + // }).toList(); + // if (newItems.length != _dataStreamController.value.items.length) { + // // item of interest + // _dataStreamController.addWithValue((value) => value.copyWith( + // items: newItems, + // )); + // } + } + + final DiContainer _c; + final Account account; + Collection collection; + ValueChanged onCollectionUpdated; + + var _isDataStreamInited = false; + final _dataStreamController = BehaviorSubject.seeded( + const CollectionItemStreamData( + items: [], + hasNext: true, + ), + ); + + late final _fileRemovedEventListener = + AppEventListener(_onFileRemovedEvent); + + final _mutex = Mutex(); +} diff --git a/app/lib/controller/collection_items_controller.g.dart b/app/lib/controller/collection_items_controller.g.dart new file mode 100644 index 00000000..184135ad --- /dev/null +++ b/app/lib/controller/collection_items_controller.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'collection_items_controller.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $CollectionItemStreamDataCopyWithWorker { + CollectionItemStreamData call({List? items, bool? hasNext}); +} + +class _$CollectionItemStreamDataCopyWithWorkerImpl + implements $CollectionItemStreamDataCopyWithWorker { + _$CollectionItemStreamDataCopyWithWorkerImpl(this.that); + + @override + CollectionItemStreamData call({dynamic items, dynamic hasNext}) { + return CollectionItemStreamData( + items: items as List? ?? that.items, + hasNext: hasNext as bool? ?? that.hasNext); + } + + final CollectionItemStreamData that; +} + +extension $CollectionItemStreamDataCopyWith on CollectionItemStreamData { + $CollectionItemStreamDataCopyWithWorker get copyWith => _$copyWith; + $CollectionItemStreamDataCopyWithWorker get _$copyWith => + _$CollectionItemStreamDataCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionItemsControllerNpLog on CollectionItemsController { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger( + "controller.collection_items_controller.CollectionItemsController"); +} diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart new file mode 100644 index 00000000..52d7cdbf --- /dev/null +++ b/app/lib/controller/collections_controller.dart @@ -0,0 +1,274 @@ +import 'dart:async'; + +import 'package:copy_with/copy_with.dart'; +import 'package:logging/logging.dart'; +import 'package:mutex/mutex.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/collection_items_controller.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/rx_extension.dart'; +import 'package:nc_photos/use_case/collection/create_collection.dart'; +import 'package:nc_photos/use_case/collection/edit_collection.dart'; +import 'package:nc_photos/use_case/collection/list_collection.dart'; +import 'package:nc_photos/use_case/collection/remove_collections.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'collections_controller.g.dart'; + +@genCopyWith +class CollectionStreamData { + const CollectionStreamData({ + required this.collection, + required this.controller, + }); + + final Collection collection; + final CollectionItemsController controller; +} + +@genCopyWith +class CollectionStreamEvent { + const CollectionStreamEvent({ + required this.data, + required this.hasNext, + }); + + CollectionItemsController itemsControllerByCollection(Collection collection) { + final i = data.indexWhere((d) => collection.compareIdentity(d.collection)); + return data[i].controller; + } + + final List data; + + /// If true, the results are intermediate values and may not represent the + /// latest state + final bool hasNext; +} + +@npLog +class CollectionsController { + CollectionsController( + this._c, { + required this.account, + }); + + void dispose() { + _dataStreamController.close(); + for (final c in _itemControllers.values) { + c.dispose(); + } + } + + /// Return a stream of collections associated with [account] + /// + /// The returned stream will emit new list of collections whenever there are + /// changes to the collections (e.g., new collection, removed collection, etc) + /// + /// There's no guarantee that the returned list is always sorted in some ways, + /// callers must sort it by themselves if the ordering is important + ValueStream get stream { + if (!_isDataStreamInited) { + _isDataStreamInited = true; + unawaited(_load()); + } + return _dataStreamController.stream; + } + + /// Peek the stream and return the current value + CollectionStreamEvent peekStream() => _dataStreamController.stream.value; + + Future createNew(Collection collection) async { + // we can't simply add the collection argument to the stream because + // the collection may not be a complete workable instance + final created = await CreateCollection(_c)(account, collection); + _dataStreamController.addWithValue((v) => v.copyWith( + data: _prepareDataFor([ + created, + ...v.data.map((e) => e.collection), + ], shouldRemoveCache: false), + )); + return created; + } + + /// Remove [collections] and return the removed count + /// + /// If [onError] is not null, you'll get notified about the errors. The future + /// will always complete normally + Future remove( + List collections, { + ErrorWithValueHandler? onError, + }) async { + final newData = _dataStreamController.value.data.toList(); + final toBeRemoved = []; + var failedCount = 0; + for (final c in collections) { + final i = newData.indexWhere((d) => c.compareIdentity(d.collection)); + if (i == -1) { + _log.warning("[remove] Collection not found: $c"); + } else { + toBeRemoved.add(newData.removeAt(i)); + } + } + _dataStreamController.addWithValue((v) => v.copyWith( + data: newData, + )); + + final restore = []; + await _mutex.protect(() async { + await RemoveCollections(_c)( + account, + collections, + onError: (c, e, stackTrace) { + _log.severe( + "[remove] Failed while RemoveCollections: $c", e, stackTrace); + final i = + toBeRemoved.indexWhere((d) => c.compareIdentity(d.collection)); + if (i != -1) { + restore.add(toBeRemoved.removeAt(i)); + } + ++failedCount; + onError?.call(c, e, stackTrace); + }, + ); + }); + toBeRemoved + .map((d) => _CollectionKey(d.collection)) + .forEach(_itemControllers.remove); + if (restore.isNotEmpty) { + _log.severe("[remove] Restoring ${restore.length} collections"); + _dataStreamController.addWithValue((v) => v.copyWith( + data: [ + ...restore, + ...v.data, + ], + )); + } + return collections.length - failedCount; + } + + /// See [EditCollection] + Future edit( + Collection collection, { + String? name, + List? items, + CollectionItemSort? itemSort, + }) async { + try { + final c = await _mutex.protect(() async { + return await EditCollection(_c)( + account, + collection, + name: name, + items: items, + itemSort: itemSort, + ); + }); + _updateCollection(c, items); + } catch (e, stackTrace) { + _dataStreamController.addError(e, stackTrace); + } + } + + Future _load() async { + var lastData = const CollectionStreamEvent( + data: [], + hasNext: false, + ); + final completer = Completer(); + ListCollection(_c)(account).listen( + (c) { + lastData = CollectionStreamEvent( + data: _prepareDataFor(c, shouldRemoveCache: true), + hasNext: true, + ); + _dataStreamController.add(lastData); + }, + onError: _dataStreamController.addError, + onDone: () => completer.complete(), + ); + await completer.future; + _dataStreamController.add(lastData.copyWith(hasNext: false)); + } + + List _prepareDataFor( + List collections, { + required bool shouldRemoveCache, + }) { + final data = []; + final keys = <_CollectionKey>[]; + for (final c in collections) { + final k = _CollectionKey(c); + _itemControllers[k] ??= CollectionItemsController( + _c, + account: account, + collection: k.collection, + onCollectionUpdated: _updateCollection, + ); + data.add(CollectionStreamData( + collection: c, + controller: _itemControllers[k]!, + )); + keys.add(k); + } + + final remove = + _itemControllers.keys.where((k) => !keys.contains(k)).toList(); + for (final k in remove) { + _itemControllers[k]?.dispose(); + _itemControllers.remove(k); + } + + return data; + } + + void _updateCollection(Collection collection, [List? items]) { + _log.info("[_updateCollection] Updating collection: $collection"); + _dataStreamController.addWithValue((v) => v.copyWith( + data: v.data.map((d) { + if (d.collection.compareIdentity(collection)) { + if (items != null) { + d.controller.forceReplaceItems(items); + } + return d.copyWith(collection: collection); + } else { + return d; + } + }).toList(), + )); + } + + final DiContainer _c; + final Account account; + + var _isDataStreamInited = false; + final _dataStreamController = BehaviorSubject.seeded( + const CollectionStreamEvent( + data: [], + hasNext: true, + ), + ); + + final _itemControllers = <_CollectionKey, CollectionItemsController>{}; + + final _mutex = Mutex(); +} + +class _CollectionKey { + const _CollectionKey(this.collection); + + @override + bool operator ==(Object other) { + return other is _CollectionKey && + collection.compareIdentity(other.collection); + } + + @override + int get hashCode => collection.identityHashCode; + + final Collection collection; +} diff --git a/app/lib/controller/collections_controller.g.dart b/app/lib/controller/collections_controller.g.dart new file mode 100644 index 00000000..6b72dd22 --- /dev/null +++ b/app/lib/controller/collections_controller.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'collections_controller.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $CollectionStreamDataCopyWithWorker { + CollectionStreamData call( + {Collection? collection, CollectionItemsController? controller}); +} + +class _$CollectionStreamDataCopyWithWorkerImpl + implements $CollectionStreamDataCopyWithWorker { + _$CollectionStreamDataCopyWithWorkerImpl(this.that); + + @override + CollectionStreamData call({dynamic collection, dynamic controller}) { + return CollectionStreamData( + collection: collection as Collection? ?? that.collection, + controller: + controller as CollectionItemsController? ?? that.controller); + } + + final CollectionStreamData that; +} + +extension $CollectionStreamDataCopyWith on CollectionStreamData { + $CollectionStreamDataCopyWithWorker get copyWith => _$copyWith; + $CollectionStreamDataCopyWithWorker get _$copyWith => + _$CollectionStreamDataCopyWithWorkerImpl(this); +} + +abstract class $CollectionStreamEventCopyWithWorker { + CollectionStreamEvent call({List? data, bool? hasNext}); +} + +class _$CollectionStreamEventCopyWithWorkerImpl + implements $CollectionStreamEventCopyWithWorker { + _$CollectionStreamEventCopyWithWorkerImpl(this.that); + + @override + CollectionStreamEvent call({dynamic data, dynamic hasNext}) { + return CollectionStreamEvent( + data: data as List? ?? that.data, + hasNext: hasNext as bool? ?? that.hasNext); + } + + final CollectionStreamEvent that; +} + +extension $CollectionStreamEventCopyWith on CollectionStreamEvent { + $CollectionStreamEventCopyWithWorker get copyWith => _$copyWith; + $CollectionStreamEventCopyWithWorker get _$copyWith => + _$CollectionStreamEventCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionsControllerNpLog on CollectionsController { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("controller.collections_controller.CollectionsController"); +} diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart new file mode 100644 index 00000000..aa36a6cf --- /dev/null +++ b/app/lib/controller/pref_controller.dart @@ -0,0 +1,54 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'pref_controller.g.dart'; + +@npLog +class PrefController { + PrefController(this._c); + + ValueStream get albumBrowserZoomLevel => + _albumBrowserZoomLevelController.stream; + + Future setAlbumBrowserZoomLevel(int value) async { + final backup = _albumBrowserZoomLevelController.value; + _albumBrowserZoomLevelController.add(value); + try { + if (!await _c.pref.setAlbumBrowserZoomLevel(value)) { + throw StateError("Unknown error"); + } + } catch (e, stackTrace) { + _log.severe("[setAlbumBrowserZoomLevel] Failed setting preference", e, + stackTrace); + _albumBrowserZoomLevelController + ..addError(e, stackTrace) + ..add(backup); + } + } + + ValueStream get homeAlbumsSort => _homeAlbumsSortController.stream; + + Future setHomeAlbumsSort(int value) async { + final backup = _homeAlbumsSortController.value; + _homeAlbumsSortController.add(value); + try { + if (!await _c.pref.setHomeAlbumsSort(value)) { + throw StateError("Unknown error"); + } + } catch (e, stackTrace) { + _log.severe( + "[setHomeAlbumsSort] Failed setting preference", e, stackTrace); + _homeAlbumsSortController + ..addError(e, stackTrace) + ..add(backup); + } + } + + final DiContainer _c; + late final _albumBrowserZoomLevelController = + BehaviorSubject.seeded(_c.pref.getAlbumBrowserZoomLevelOr(0)); + late final _homeAlbumsSortController = + BehaviorSubject.seeded(_c.pref.getHomeAlbumsSortOr(0)); +} diff --git a/app/lib/widget/album_picker.g.dart b/app/lib/controller/pref_controller.g.dart similarity index 63% rename from app/lib/widget/album_picker.g.dart rename to app/lib/controller/pref_controller.g.dart index 88b19827..fc2a8e28 100644 --- a/app/lib/widget/album_picker.g.dart +++ b/app/lib/controller/pref_controller.g.dart @@ -1,14 +1,14 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'album_picker.dart'; +part of 'pref_controller.dart'; // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$_AlbumPickerStateNpLog on _AlbumPickerState { +extension _$PrefControllerNpLog on PrefController { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.album_picker._AlbumPickerState"); + static final log = Logger("controller.pref_controller.PrefController"); } diff --git a/app/lib/di_container.dart b/app/lib/di_container.dart index a0ed4ca7..e2fcc408 100644 --- a/app/lib/di_container.dart +++ b/app/lib/di_container.dart @@ -1,8 +1,10 @@ import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/repo2.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/entity/nc_album/repo.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/search.dart'; import 'package:nc_photos/entity/share.dart'; @@ -16,7 +18,11 @@ import 'package:nc_photos/touch_manager.dart'; enum DiType { albumRepo, + albumRepoRemote, albumRepoLocal, + albumRepo2, + albumRepo2Remote, + albumRepo2Local, faceRepo, fileRepo, fileRepoRemote, @@ -33,6 +39,9 @@ enum DiType { taggedFileRepo, localFileRepo, searchRepo, + ncAlbumRepo, + ncAlbumRepoRemote, + ncAlbumRepoLocal, pref, sqliteDb, touchManager, @@ -41,7 +50,11 @@ enum DiType { class DiContainer { DiContainer({ AlbumRepo? albumRepo, + AlbumRepo? albumRepoRemote, AlbumRepo? albumRepoLocal, + AlbumRepo2? albumRepo2, + AlbumRepo2? albumRepo2Remote, + AlbumRepo2? albumRepo2Local, FaceRepo? faceRepo, FileRepo? fileRepo, FileRepo? fileRepoRemote, @@ -58,11 +71,18 @@ class DiContainer { TaggedFileRepo? taggedFileRepo, LocalFileRepo? localFileRepo, SearchRepo? searchRepo, + NcAlbumRepo? ncAlbumRepo, + NcAlbumRepo? ncAlbumRepoRemote, + NcAlbumRepo? ncAlbumRepoLocal, Pref? pref, sql.SqliteDb? sqliteDb, TouchManager? touchManager, }) : _albumRepo = albumRepo, + _albumRepoRemote = albumRepoRemote, _albumRepoLocal = albumRepoLocal, + _albumRepo2 = albumRepo2, + _albumRepo2Remote = albumRepo2Remote, + _albumRepo2Local = albumRepo2Local, _faceRepo = faceRepo, _fileRepo = fileRepo, _fileRepoRemote = fileRepoRemote, @@ -79,6 +99,9 @@ class DiContainer { _taggedFileRepo = taggedFileRepo, _localFileRepo = localFileRepo, _searchRepo = searchRepo, + _ncAlbumRepo = ncAlbumRepo, + _ncAlbumRepoRemote = ncAlbumRepoRemote, + _ncAlbumRepoLocal = ncAlbumRepoLocal, _pref = pref, _sqliteDb = sqliteDb, _touchManager = touchManager; @@ -89,8 +112,16 @@ class DiContainer { switch (type) { case DiType.albumRepo: return contianer._albumRepo != null; + case DiType.albumRepoRemote: + return contianer._albumRepoRemote != null; case DiType.albumRepoLocal: return contianer._albumRepoLocal != null; + case DiType.albumRepo2: + return contianer._albumRepo2 != null; + case DiType.albumRepo2Remote: + return contianer._albumRepo2Remote != null; + case DiType.albumRepo2Local: + return contianer._albumRepo2Local != null; case DiType.faceRepo: return contianer._faceRepo != null; case DiType.fileRepo: @@ -123,6 +154,12 @@ class DiContainer { return contianer._localFileRepo != null; case DiType.searchRepo: return contianer._searchRepo != null; + case DiType.ncAlbumRepo: + return contianer._ncAlbumRepo != null; + case DiType.ncAlbumRepoRemote: + return contianer._ncAlbumRepoRemote != null; + case DiType.ncAlbumRepoLocal: + return contianer._ncAlbumRepoLocal != null; case DiType.pref: return contianer._pref != null; case DiType.sqliteDb: @@ -134,6 +171,7 @@ class DiContainer { DiContainer copyWith({ OrNull? albumRepo, + OrNull? albumRepo2, OrNull? faceRepo, OrNull? fileRepo, OrNull? personRepo, @@ -144,12 +182,14 @@ class DiContainer { OrNull? taggedFileRepo, OrNull? localFileRepo, OrNull? searchRepo, + OrNull? ncAlbumRepo, OrNull? pref, OrNull? sqliteDb, OrNull? touchManager, }) { return DiContainer( albumRepo: albumRepo == null ? _albumRepo : albumRepo.obj, + albumRepo2: albumRepo2 == null ? _albumRepo2 : albumRepo2.obj, faceRepo: faceRepo == null ? _faceRepo : faceRepo.obj, fileRepo: fileRepo == null ? _fileRepo : fileRepo.obj, personRepo: personRepo == null ? _personRepo : personRepo.obj, @@ -161,6 +201,7 @@ class DiContainer { taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj, localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj, searchRepo: searchRepo == null ? _searchRepo : searchRepo.obj, + ncAlbumRepo: ncAlbumRepo == null ? _ncAlbumRepo : ncAlbumRepo.obj, pref: pref == null ? _pref : pref.obj, sqliteDb: sqliteDb == null ? _sqliteDb : sqliteDb.obj, touchManager: touchManager == null ? _touchManager : touchManager.obj, @@ -168,7 +209,11 @@ class DiContainer { } AlbumRepo get albumRepo => _albumRepo!; + AlbumRepo get albumRepoRemote => _albumRepoRemote!; AlbumRepo get albumRepoLocal => _albumRepoLocal!; + AlbumRepo2 get albumRepo2 => _albumRepo2!; + AlbumRepo2 get albumRepo2Remote => _albumRepo2Remote!; + AlbumRepo2 get albumRepo2Local => _albumRepo2Local!; FaceRepo get faceRepo => _faceRepo!; FileRepo get fileRepo => _fileRepo!; FileRepo get fileRepoRemote => _fileRepoRemote!; @@ -185,6 +230,9 @@ class DiContainer { TaggedFileRepo get taggedFileRepo => _taggedFileRepo!; LocalFileRepo get localFileRepo => _localFileRepo!; SearchRepo get searchRepo => _searchRepo!; + NcAlbumRepo get ncAlbumRepo => _ncAlbumRepo!; + NcAlbumRepo get ncAlbumRepoRemote => _ncAlbumRepoRemote!; + NcAlbumRepo get ncAlbumRepoLocal => _ncAlbumRepoLocal!; TouchManager get touchManager => _touchManager!; sql.SqliteDb get sqliteDb => _sqliteDb!; @@ -195,11 +243,31 @@ class DiContainer { _albumRepo = v; } + set albumRepoRemote(AlbumRepo v) { + assert(_albumRepoRemote == null); + _albumRepoRemote = v; + } + set albumRepoLocal(AlbumRepo v) { assert(_albumRepoLocal == null); _albumRepoLocal = v; } + set albumRepo2(AlbumRepo2 v) { + assert(_albumRepo2 == null); + _albumRepo2 = v; + } + + set albumRepo2Remote(AlbumRepo2 v) { + assert(_albumRepo2Remote == null); + _albumRepo2Remote = v; + } + + set albumRepo2Local(AlbumRepo2 v) { + assert(_albumRepo2Local == null); + _albumRepo2Local = v; + } + set faceRepo(FaceRepo v) { assert(_faceRepo == null); _faceRepo = v; @@ -280,6 +348,21 @@ class DiContainer { _searchRepo = v; } + set ncAlbumRepo(NcAlbumRepo v) { + assert(_ncAlbumRepo == null); + _ncAlbumRepo = v; + } + + set ncAlbumRepoRemote(NcAlbumRepo v) { + assert(_ncAlbumRepoRemote == null); + _ncAlbumRepoRemote = v; + } + + set ncAlbumRepoLocal(NcAlbumRepo v) { + assert(_ncAlbumRepoLocal == null); + _ncAlbumRepoLocal = v; + } + set touchManager(TouchManager v) { assert(_touchManager == null); _touchManager = v; @@ -296,9 +379,13 @@ class DiContainer { } AlbumRepo? _albumRepo; + AlbumRepo? _albumRepoRemote; // Explicitly request a AlbumRepo backed by local source AlbumRepo? _albumRepoLocal; FaceRepo? _faceRepo; + AlbumRepo2? _albumRepo2; + AlbumRepo2? _albumRepo2Remote; + AlbumRepo2? _albumRepo2Local; FileRepo? _fileRepo; // Explicitly request a FileRepo backed by remote source FileRepo? _fileRepoRemote; @@ -316,6 +403,9 @@ class DiContainer { TaggedFileRepo? _taggedFileRepo; LocalFileRepo? _localFileRepo; SearchRepo? _searchRepo; + NcAlbumRepo? _ncAlbumRepo; + NcAlbumRepo? _ncAlbumRepoRemote; + NcAlbumRepo? _ncAlbumRepoLocal; TouchManager? _touchManager; sql.SqliteDb? _sqliteDb; @@ -323,14 +413,28 @@ class DiContainer { } extension DiContainerExtension on DiContainer { + /// Uses remote repo if available + /// + /// Notice that not all repo support this + DiContainer withRemoteRepo() => copyWith( + albumRepo: OrNull(albumRepoRemote), + albumRepo2: OrNull(albumRepo2Remote), + fileRepo: OrNull(fileRepoRemote), + personRepo: OrNull(personRepoRemote), + tagRepo: OrNull(tagRepoRemote), + ncAlbumRepo: OrNull(ncAlbumRepoRemote), + ); + /// Uses local repo if available /// /// Notice that not all repo support this DiContainer withLocalRepo() => copyWith( albumRepo: OrNull(albumRepoLocal), + albumRepo2: OrNull(albumRepo2Local), fileRepo: OrNull(fileRepoLocal), personRepo: OrNull(personRepoLocal), tagRepo: OrNull(tagRepoLocal), + ncAlbumRepo: OrNull(ncAlbumRepoLocal), ); DiContainer withLocalAlbumRepo() => diff --git a/app/lib/entity/album/data_source.dart b/app/lib/entity/album/data_source.dart index 37de0c96..94e93f07 100644 --- a/app/lib/entity/album/data_source.dart +++ b/app/lib/entity/album/data_source.dart @@ -1,106 +1,76 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:clock/clock.dart'; -import 'package:drift/drift.dart' as sql; -import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/upgrader.dart'; +import 'package:nc_photos/entity/album/data_source2.dart'; +import 'package:nc_photos/entity/album/repo2.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; -import 'package:nc_photos/entity/sqlite/type_converter.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_event.dart'; -import 'package:nc_photos/future_util.dart' as future_util; import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; -import 'package:nc_photos/use_case/get_file_binary.dart'; -import 'package:nc_photos/use_case/ls_single_file.dart'; -import 'package:nc_photos/use_case/put_file_binary.dart'; import 'package:np_codegen/np_codegen.dart'; part 'data_source.g.dart'; +/// Backward compatibility only, use [AlbumRemoteDataSource2] instead @npLog class AlbumRemoteDataSource implements AlbumDataSource { @override get(Account account, File albumFile) async { _log.info("[get] ${albumFile.path}"); - const fileRepo = FileRepo(FileWebdavDataSource()); - final data = await GetFileBinary(fileRepo)(account, albumFile); - try { - return Album.fromJson( - jsonDecode(utf8.decode(data)), - upgraderFactory: DefaultAlbumUpgraderFactory( - account: account, - albumFile: albumFile, - logFilePath: albumFile.path, - ), - )! - .copyWith( - lastUpdated: OrNull(null), - albumFile: OrNull(albumFile), - ); - } catch (e, stacktrace) { - dynamic d = data; - try { - d = utf8.decode(data); - } catch (_) {} - _log.severe("[get] Invalid json data: $d", e, stacktrace); - throw const FormatException("Invalid album format"); - } + final albums = await const AlbumRemoteDataSource2().getAlbums( + account, + [albumFile], + onError: (_, error, stackTrace) { + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); + }, + ); + return albums.first; } @override getAll(Account account, List albumFiles) async* { _log.info( "[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}"); - final results = await future_util.waitOr( - albumFiles.map((f) => get(account, f)), - (error, stackTrace) => ExceptionEvent(error, stackTrace), + final failed = {}; + final albums = await const AlbumRemoteDataSource2().getAlbums( + account, + albumFiles, + onError: (v, error, stackTrace) { + failed[v.path] = { + "file": v, + "error": error, + "stackTrace": stackTrace, + }; + }, ); - for (final r in results) { - yield r; + var i = 0; + for (final af in albumFiles) { + final v = failed[af.path]; + if (v != null) { + yield ExceptionEvent(v["error"], v["stackTrace"]); + } else { + yield albums[i++]; + } } } @override create(Account account, Album album) async { _log.info("[create]"); - final fileName = _makeAlbumFileName(); - final filePath = - "${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName"; - final c = KiwiContainer().resolve(); - await PutFileBinary(c.fileRepo)(account, filePath, - const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())), - shouldCreateMissingDir: true); - // query album file - final newFile = await LsSingleFile(c)(account, filePath); - return album.copyWith(albumFile: OrNull(newFile)); + return const AlbumRemoteDataSource2().create(account, album); } @override update(Account account, Album album) async { _log.info("[update] ${album.albumFile!.path}"); - const fileRepo = FileRepo(FileWebdavDataSource()); - await PutFileBinary(fileRepo)(account, album.albumFile!.path, - const Utf8Encoder().convert(jsonEncode(album.toRemoteJson()))); - } - - String _makeAlbumFileName() { - // just make up something - final timestamp = clock.now().millisecondsSinceEpoch; - final random = Random().nextInt(0xFFFFFF); - return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json"; + return const AlbumRemoteDataSource2().update(account, album); } } +/// Backward compatibility only, use [AlbumSqliteDbDataSource2] instead @npLog class AlbumSqliteDbDataSource implements AlbumDataSource { AlbumSqliteDbDataSource(this._c); @@ -119,64 +89,29 @@ class AlbumSqliteDbDataSource implements AlbumDataSource { getAll(Account account, List albumFiles) async* { _log.info( "[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}"); - late final List dbFiles; - late final List albumWithShares; - await _c.sqliteDb.use((db) async { - dbFiles = await db.completeFilesByFileIds( - albumFiles.map((f) => f.fileId!), - appAccount: account, - ); - final query = db.select(db.albums).join([ - sql.leftOuterJoin( - db.albumShares, db.albumShares.album.equalsExp(db.albums.rowId)), - ]) - ..where(db.albums.file.isIn(dbFiles.map((f) => f.file.rowId))); - albumWithShares = await query - .map((r) => sql.AlbumWithShare( - r.readTable(db.albums), r.readTableOrNull(db.albumShares))) - .get(); - }); - - // group entries together - final fileRowIdMap = {}; - for (var f in dbFiles) { - fileRowIdMap[f.file.rowId] = f; - } - final fileIdMap = {}; - for (final s in albumWithShares) { - final f = fileRowIdMap[s.album.file]; - if (f == null) { - _log.severe("[getAll] File missing for album (rowId: ${s.album.rowId}"); + final failed = {}; + final albums = await AlbumSqliteDbDataSource2(_c.sqliteDb).getAlbums( + account, + albumFiles, + onError: (v, error, stackTrace) { + failed[v.path] = { + "file": v, + "error": error, + "stackTrace": stackTrace, + }; + }, + ); + var i = 0; + for (final af in albumFiles) { + final v = failed[af.path]; + if (v != null) { + if (v["error"] is CacheNotFoundException) { + yield const CacheNotFoundException(); + } else { + yield ExceptionEvent(v["error"], v["stackTrace"]); + } } else { - if (!fileIdMap.containsKey(f.file.fileId)) { - fileIdMap[f.file.fileId] = { - "file": f, - "album": s.album, - }; - } - if (s.share != null) { - (fileIdMap[f.file.fileId]!["shares"] ??= []) - .add(s.share!); - } - } - } - - // sort as the input list - for (final item in albumFiles.map((f) => fileIdMap[f.fileId])) { - if (item == null) { - // cache not found - yield CacheNotFoundException(); - } else { - try { - final f = SqliteFileConverter.fromSql( - account.userId.toString(), item["file"]); - yield SqliteAlbumConverter.fromSql( - item["album"], f, item["shares"] ?? []); - } catch (e, stackTrace) { - _log.severe( - "[getAll] Failed while converting DB entry", e, stackTrace); - yield ExceptionEvent(e, stackTrace); - } + yield albums[i++]; } } } @@ -184,64 +119,22 @@ class AlbumSqliteDbDataSource implements AlbumDataSource { @override create(Account account, Album album) async { _log.info("[create]"); - throw UnimplementedError(); + return AlbumSqliteDbDataSource2(_c.sqliteDb).create(account, album); } @override update(Account account, Album album) async { _log.info("[update] ${album.albumFile!.path}"); - await _c.sqliteDb.use((db) async { - final rowIds = - await db.accountFileRowIdsOf(album.albumFile!, appAccount: account); - final insert = SqliteAlbumConverter.toSql( - album, rowIds.fileRowId, album.albumFile!.etag!); - var rowId = await _updateCache(db, rowIds.fileRowId, insert.album); - if (rowId == null) { - // new album, need insert - _log.info("[update] Insert new album"); - final insertedAlbum = - await db.into(db.albums).insertReturning(insert.album); - rowId = insertedAlbum.rowId; - } else { - await (db.delete(db.albumShares)..where((t) => t.album.equals(rowId))) - .go(); - } - if (insert.albumShares.isNotEmpty) { - await db.batch((batch) { - batch.insertAll( - db.albumShares, - insert.albumShares.map((s) => s.copyWith(album: sql.Value(rowId!))), - ); - }); - } - }); - } - - Future _updateCache( - sql.SqliteDb db, int dbFileRowId, sql.AlbumsCompanion dbAlbum) async { - final rowIdQuery = db.selectOnly(db.albums) - ..addColumns([db.albums.rowId]) - ..where(db.albums.file.equals(dbFileRowId)) - ..limit(1); - final rowId = - await rowIdQuery.map((r) => r.read(db.albums.rowId)!).getSingleOrNull(); - if (rowId == null) { - // new album - return null; - } - - await (db.update(db.albums)..where((t) => t.rowId.equals(rowId))) - .write(dbAlbum); - return rowId; + return AlbumSqliteDbDataSource2(_c.sqliteDb).update(account, album); } final DiContainer _c; } +/// Backward compatibility only, use [CachedAlbumRepo2] instead @npLog class AlbumCachedDataSource implements AlbumDataSource { - AlbumCachedDataSource(DiContainer c) - : _sqliteDbSrc = AlbumSqliteDbDataSource(c); + AlbumCachedDataSource(DiContainer c) : sqliteDb = c.sqliteDb; @override get(Account account, File albumFile) async { @@ -251,58 +144,31 @@ class AlbumCachedDataSource implements AlbumDataSource { @override getAll(Account account, List albumFiles) async* { - var i = 0; - await for (final cache in _sqliteDbSrc.getAll(account, albumFiles)) { - final albumFile = albumFiles[i++]; - if (_validateCache(cache, albumFile)) { - yield cache; - } else { - // no cache - final remote = await _remoteSrc.get(account, albumFile); - await _cacheResult(account, remote); - yield remote; - } + final repo = CachedAlbumRepo2( + const AlbumRemoteDataSource2(), + AlbumSqliteDbDataSource2(sqliteDb), + ); + final albums = await repo.getAlbums(account, albumFiles).last; + for (final a in albums) { + yield a; } } @override - update(Account account, Album album) async { - await _remoteSrc.update(account, album); - await _sqliteDbSrc.update(account, album); + update(Account account, Album album) { + return CachedAlbumRepo2( + const AlbumRemoteDataSource2(), + AlbumSqliteDbDataSource2(sqliteDb), + ).update(account, album); } @override - create(Account account, Album album) => _remoteSrc.create(account, album); - - Future _cacheResult(Account account, Album result) { - return _sqliteDbSrc.update(account, result); + create(Account account, Album album) { + return CachedAlbumRepo2( + const AlbumRemoteDataSource2(), + AlbumSqliteDbDataSource2(sqliteDb), + ).create(account, album); } - bool _validateCache(dynamic cache, File albumFile) { - if (cache is Album) { - if (cache.albumFile!.etag?.isNotEmpty == true && - cache.albumFile!.etag == albumFile.etag) { - // cache is good - _log.fine("[_validateCache] etag matched for ${albumFile.path}"); - return true; - } else { - _log.info( - "[_validateCache] Remote content updated for ${albumFile.path}"); - return false; - } - } else if (cache is CacheNotFoundException) { - // normal when there's no cache - return false; - } else if (cache is ExceptionEvent) { - _log.shout( - "[_validateCache] Cache failure", cache.error, cache.stackTrace); - return false; - } else { - _log.shout("[_validateCache] Unknown type: ${cache.runtimeType}"); - return false; - } - } - - final _remoteSrc = AlbumRemoteDataSource(); - final AlbumSqliteDbDataSource _sqliteDbSrc; + final sql.SqliteDb sqliteDb; } diff --git a/app/lib/entity/album/data_source2.dart b/app/lib/entity/album/data_source2.dart new file mode 100644 index 00000000..91cbef37 --- /dev/null +++ b/app/lib/entity/album/data_source2.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart' as sql; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/repo2.dart'; +import 'package:nc_photos/entity/album/upgrader.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; +import 'package:nc_photos/entity/sqlite/type_converter.dart' as sql; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/use_case/get_file_binary.dart'; +import 'package:nc_photos/use_case/ls_single_file.dart'; +import 'package:nc_photos/use_case/put_file_binary.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'data_source2.g.dart'; + +@npLog +class AlbumRemoteDataSource2 implements AlbumDataSource2 { + const AlbumRemoteDataSource2(); + + @override + Future> getAlbums( + Account account, + List albumFiles, { + ErrorWithValueHandler? onError, + }) async { + final results = await Future.wait(albumFiles.map((f) async { + try { + return await _getSingle(account, f); + } catch (e, stackTrace) { + onError?.call(f, e, stackTrace); + return null; + } + })); + return results.whereNotNull().toList(); + } + + @override + Future create(Account account, Album album) async { + _log.info("[create] ${album.name}"); + final fileName = _makeAlbumFileName(); + final filePath = + "${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName"; + final c = KiwiContainer().resolve(); + await PutFileBinary(c.fileRepo)( + account, + filePath, + const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())), + shouldCreateMissingDir: true, + ); + // query album file + final newFile = await LsSingleFile(c)(account, filePath); + return album.copyWith(albumFile: OrNull(newFile)); + } + + @override + Future update(Account account, Album album) async { + _log.info("[update] ${album.albumFile!.path}"); + const fileRepo = FileRepo(FileWebdavDataSource()); + await PutFileBinary(fileRepo)( + account, + album.albumFile!.path, + const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())), + ); + } + + Future _getSingle(Account account, File albumFile) async { + _log.info("[_getSingle] Getting ${albumFile.path}"); + const fileRepo = FileRepo(FileWebdavDataSource()); + final data = await GetFileBinary(fileRepo)(account, albumFile); + try { + final album = Album.fromJson( + jsonDecode(utf8.decode(data)), + upgraderFactory: DefaultAlbumUpgraderFactory( + account: account, + albumFile: albumFile, + logFilePath: albumFile.path, + ), + ); + return album!.copyWith( + lastUpdated: OrNull(null), + albumFile: OrNull(albumFile), + ); + } catch (e, stacktrace) { + dynamic d = data; + try { + d = utf8.decode(data); + } catch (_) {} + _log.severe("[_getSingle] Invalid json data: $d", e, stacktrace); + throw const FormatException("Invalid album format"); + } + } + + String _makeAlbumFileName() { + // just make up something + final timestamp = clock.now().millisecondsSinceEpoch; + final random = Random().nextInt(0xFFFFFF); + return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json"; + } +} + +@npLog +class AlbumSqliteDbDataSource2 implements AlbumDataSource2 { + const AlbumSqliteDbDataSource2(this.sqliteDb); + + @override + Future> getAlbums( + Account account, + List albumFiles, { + ErrorWithValueHandler? onError, + }) async { + late final List dbFiles; + late final List albumWithShares; + await sqliteDb.use((db) async { + dbFiles = await db.completeFilesByFileIds( + albumFiles.map((f) => f.fileId!), + appAccount: account, + ); + final query = db.select(db.albums).join([ + sql.leftOuterJoin( + db.albumShares, db.albumShares.album.equalsExp(db.albums.rowId)), + ]) + ..where(db.albums.file.isIn(dbFiles.map((f) => f.file.rowId))); + albumWithShares = await query + .map((r) => sql.AlbumWithShare( + r.readTable(db.albums), r.readTableOrNull(db.albumShares))) + .get(); + }); + + // group entries together + final fileRowIdMap = {}; + for (var f in dbFiles) { + fileRowIdMap[f.file.rowId] = f; + } + final fileIdMap = {}; + for (final s in albumWithShares) { + final f = fileRowIdMap[s.album.file]; + if (f == null) { + _log.severe( + "[getAlbums] File missing for album (rowId: ${s.album.rowId}"); + } else { + fileIdMap[f.file.fileId] ??= { + "file": f, + "album": s.album, + }; + if (s.share != null) { + (fileIdMap[f.file.fileId]!["shares"] ??= []) + .add(s.share!); + } + } + } + + // sort as the input list + return albumFiles + .map((f) { + final item = fileIdMap[f.fileId]; + if (item == null) { + // cache not found + onError?.call( + f, const CacheNotFoundException(), StackTrace.current); + return null; + } else { + try { + final queriedFile = sql.SqliteFileConverter.fromSql( + account.userId.toString(), item["file"]); + return sql.SqliteAlbumConverter.fromSql( + item["album"], queriedFile, item["shares"] ?? []); + } catch (e, stackTrace) { + _log.severe("[getAlbums] Failed while converting DB entry", e, + stackTrace); + onError?.call(f, e, stackTrace); + return null; + } + } + }) + .whereNotNull() + .toList(); + } + + @override + Future create(Account account, Album album) async { + _log.info("[create] ${album.name}"); + throw UnimplementedError(); + } + + @override + Future update(Account account, Album album) async { + _log.info("[update] ${album.albumFile!.path}"); + await sqliteDb.use((db) async { + final rowIds = + await db.accountFileRowIdsOf(album.albumFile!, appAccount: account); + final insert = sql.SqliteAlbumConverter.toSql( + album, rowIds.fileRowId, album.albumFile!.etag!); + var rowId = await _updateCache(db, rowIds.fileRowId, insert.album); + if (rowId == null) { + // new album, need insert + _log.info("[update] Insert new album"); + final insertedAlbum = + await db.into(db.albums).insertReturning(insert.album); + rowId = insertedAlbum.rowId; + } else { + await (db.delete(db.albumShares)..where((t) => t.album.equals(rowId))) + .go(); + } + if (insert.albumShares.isNotEmpty) { + await db.batch((batch) { + batch.insertAll( + db.albumShares, + insert.albumShares.map((s) => s.copyWith(album: sql.Value(rowId!))), + ); + }); + } + }); + } + + Future _updateCache( + sql.SqliteDb db, int dbFileRowId, sql.AlbumsCompanion dbAlbum) async { + final rowIdQuery = db.selectOnly(db.albums) + ..addColumns([db.albums.rowId]) + ..where(db.albums.file.equals(dbFileRowId)) + ..limit(1); + final rowId = + await rowIdQuery.map((r) => r.read(db.albums.rowId)!).getSingleOrNull(); + if (rowId == null) { + // new album + return null; + } + + await (db.update(db.albums)..where((t) => t.rowId.equals(rowId))) + .write(dbAlbum); + return rowId; + } + + final sql.SqliteDb sqliteDb; +} diff --git a/app/lib/entity/album/data_source2.g.dart b/app/lib/entity/album/data_source2.g.dart new file mode 100644 index 00000000..2fdb8cb1 --- /dev/null +++ b/app/lib/entity/album/data_source2.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_source2.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$AlbumRemoteDataSource2NpLog on AlbumRemoteDataSource2 { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.album.data_source2.AlbumRemoteDataSource2"); +} + +extension _$AlbumSqliteDbDataSource2NpLog on AlbumSqliteDbDataSource2 { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.album.data_source2.AlbumSqliteDbDataSource2"); +} diff --git a/app/lib/entity/album/repo2.dart b/app/lib/entity/album/repo2.dart new file mode 100644 index 00000000..2069489b --- /dev/null +++ b/app/lib/entity/album/repo2.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'repo2.g.dart'; + +abstract class AlbumRepo2 { + /// Query all [Album]s defined by [albumFiles] + Stream> getAlbums( + Account account, + List albumFiles, { + ErrorWithValueHandler? onError, + }); + + /// Create a new [album] + Future create(Account account, Album album); + + /// Update an [album] + Future update(Account account, Album album); +} + +class BasicAlbumRepo2 implements AlbumRepo2 { + const BasicAlbumRepo2(this.dataSrc); + + @override + Stream> getAlbums( + Account account, + List albumFiles, { + ErrorWithValueHandler? onError, + }) async* { + yield await dataSrc.getAlbums(account, albumFiles, onError: onError); + } + + @override + Future create(Account account, Album album) => + dataSrc.create(account, album); + + @override + Future update(Account account, Album album) => + dataSrc.update(account, album); + + final AlbumDataSource2 dataSrc; +} + +@npLog +class CachedAlbumRepo2 implements AlbumRepo2 { + const CachedAlbumRepo2(this.remoteDataSrc, this.cacheDataSrc); + + @override + Stream> getAlbums( + Account account, + List albumFiles, { + ErrorWithValueHandler? onError, + }) async* { + // get cache + final cached = []; + final failed = []; + try { + cached.addAll(await cacheDataSrc.getAlbums( + account, + albumFiles, + onError: (f, e, stackTrace) { + failed.add(f); + if (e is CacheNotFoundException) { + // not in cache, normal + } else { + _log.shout("[getAlbums] Cache failure", e, stackTrace); + } + }, + )); + yield cached; + } catch (e, stackTrace) { + _log.shout("[getAlbums] Failed while getAlbums", e, stackTrace); + } + final cachedGroup = cached.groupListsBy((c) { + try { + return _validateCache( + c, albumFiles.firstWhere(c.albumFile!.compareServerIdentity)); + } catch (_) { + return false; + } + }); + + // query remote + final outdated = [ + ...failed, + ...cachedGroup[false]?.map((e) => e.albumFile!) ?? const [], + ]; + final remote = + await remoteDataSrc.getAlbums(account, outdated, onError: onError); + yield (cachedGroup[true] ?? []) + remote; + + // update cache + for (final a in remote) { + unawaited(cacheDataSrc.update(account, a)); + } + } + + @override + Future create(Account account, Album album) => + remoteDataSrc.create(account, album); + + @override + Future update(Account account, Album album) async { + await remoteDataSrc.update(account, album); + try { + await cacheDataSrc.update(account, album); + } catch (e, stackTrace) { + _log.warning("[update] Failed to update cache", e, stackTrace); + } + } + + /// Return true if the cached album is considered up to date + bool _validateCache(Album cache, File albumFile) { + if (cache.albumFile!.etag?.isNotEmpty == true && + cache.albumFile!.etag == albumFile.etag) { + // cache is good + _log.fine("[_validateCache] etag matched for ${albumFile.path}"); + return true; + } else { + _log.info( + "[_validateCache] Remote content updated for ${albumFile.path}"); + return false; + } + } + + final AlbumDataSource2 remoteDataSrc; + final AlbumDataSource2 cacheDataSrc; +} + +abstract class AlbumDataSource2 { + /// Query all [Album]s defined by [albumFiles] + Future> getAlbums( + Account account, + List albumFiles, { + ErrorWithValueHandler? onError, + }); + + Future create(Account account, Album album); + + Future update(Account account, Album album); +} diff --git a/app/lib/use_case/list_favorite.g.dart b/app/lib/entity/album/repo2.g.dart similarity index 66% rename from app/lib/use_case/list_favorite.g.dart rename to app/lib/entity/album/repo2.g.dart index 49f2b6e2..443c7eb9 100644 --- a/app/lib/use_case/list_favorite.g.dart +++ b/app/lib/entity/album/repo2.g.dart @@ -1,14 +1,14 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'list_favorite.dart'; +part of 'repo2.dart'; // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$ListFavoriteNpLog on ListFavorite { +extension _$CachedAlbumRepo2NpLog on CachedAlbumRepo2 { // ignore: unused_element Logger get _log => log; - static final log = Logger("use_case.list_favorite.ListFavorite"); + static final log = Logger("entity.album.repo2.CachedAlbumRepo2"); } diff --git a/app/lib/entity/album/sort_provider.dart b/app/lib/entity/album/sort_provider.dart index 8287c0df..633e2d16 100644 --- a/app/lib/entity/album/sort_provider.dart +++ b/app/lib/entity/album/sort_provider.dart @@ -1,14 +1,12 @@ -import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/entity/collection_item/album_item_adapter.dart'; +import 'package:nc_photos/entity/collection_item/sorter.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/type.dart'; import 'package:to_string/to_string.dart'; -import 'package:tuple/tuple.dart'; part 'sort_provider.g.dart'; @@ -33,6 +31,22 @@ abstract class AlbumSortProvider with EquatableMixin { } } + factory AlbumSortProvider.fromCollectionItemSort( + CollectionItemSort itemSort) { + switch (itemSort) { + case CollectionItemSort.manual: + return const AlbumNullSortProvider(); + case CollectionItemSort.dateAscending: + return const AlbumTimeSortProvider(isAscending: true); + case CollectionItemSort.dateDescending: + return const AlbumTimeSortProvider(isAscending: false); + case CollectionItemSort.nameAscending: + return const AlbumFilenameSortProvider(isAscending: true); + case CollectionItemSort.nameDescending: + return const AlbumFilenameSortProvider(isAscending: false); + } + } + JsonObj toJson() { String getType() { if (this is AlbumNullSortProvider) { @@ -53,7 +67,31 @@ abstract class AlbumSortProvider with EquatableMixin { } /// Return a sorted copy of [items] - List sort(List items); + List sort(List items) { + final type = toCollectionItemSort(); + final sorter = CollectionSorter.fromSortType(type); + return sorter(items.map(AlbumAdaptedCollectionItem.fromItem).toList()) + .whereType() + .map((e) => e.albumItem) + .toList(); + } + + CollectionItemSort toCollectionItemSort() { + final that = this; + if (that is AlbumNullSortProvider) { + return CollectionItemSort.manual; + } else if (that is AlbumTimeSortProvider) { + return that.isAscending + ? CollectionItemSort.dateAscending + : CollectionItemSort.dateDescending; + } else if (that is AlbumFilenameSortProvider) { + return that.isAscending + ? CollectionItemSort.nameAscending + : CollectionItemSort.nameDescending; + } else { + throw StateError("Unknown type: ${sort.runtimeType}"); + } + } JsonObj _toContentJson(); @@ -72,11 +110,6 @@ class AlbumNullSortProvider extends AlbumSortProvider { @override String toString() => _$toString(); - @override - sort(List items) { - return List.from(items); - } - @override get props => []; @@ -124,37 +157,6 @@ class AlbumTimeSortProvider extends AlbumReversibleSortProvider { @override String toString() => _$toString(); - @override - sort(List items) { - DateTime? prevFileTime; - return items - .map((e) { - if (e is AlbumFileItem) { - // take the file time - prevFileTime = e.file.bestDateTime; - } - // for non file items, use the sibling file's time - return Tuple2(prevFileTime, e); - }) - .stableSorted((x, y) { - if (x.item1 == null && y.item1 == null) { - return 0; - } else if (x.item1 == null) { - return -1; - } else if (y.item1 == null) { - return 1; - } else { - if (isAscending) { - return x.item1!.compareTo(y.item1!); - } else { - return y.item1!.compareTo(x.item1!); - } - } - }) - .map((e) => e.item2) - .toList(); - } - static const _type = "time"; } @@ -174,36 +176,5 @@ class AlbumFilenameSortProvider extends AlbumReversibleSortProvider { @override String toString() => _$toString(); - @override - sort(List items) { - String? prevFilename; - return items - .map((e) { - if (e is AlbumFileItem) { - // take the file name - prevFilename = e.file.filename; - } - // for non file items, use the sibling file's name - return Tuple2(prevFilename, e); - }) - .stableSorted((x, y) { - if (x.item1 == null && y.item1 == null) { - return 0; - } else if (x.item1 == null) { - return -1; - } else if (y.item1 == null) { - return 1; - } else { - if (isAscending) { - return compareNatural(x.item1!, y.item1!); - } else { - return compareNatural(y.item1!, x.item1!); - } - } - }) - .map((e) => e.item2) - .toList(); - } - static const _type = "filename"; } diff --git a/app/lib/entity/album_util.dart b/app/lib/entity/album_util.dart deleted file mode 100644 index 39b587a7..00000000 --- a/app/lib/entity/album_util.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:tuple/tuple.dart'; - -enum AlbumSort { - dateDescending, - dateAscending, - nameAscending, - nameDescending, -} - -List sorted(List albums, AlbumSort by) { - final isAscending = _isSortAscending(by); - return albums - .map>((e) { - switch (by) { - case AlbumSort.nameAscending: - case AlbumSort.nameDescending: - return Tuple2(e.name, e); - - case AlbumSort.dateAscending: - case AlbumSort.dateDescending: - return Tuple2(e.provider.latestItemTime ?? e.lastUpdated, e); - } - }) - .sorted((a, b) { - final x = isAscending ? a : b; - final y = isAscending ? b : a; - final tmp = x.item1.compareTo(y.item1); - if (tmp != 0) { - return tmp; - } else { - return x.item2.name.compareTo(y.item2.name); - } - }) - .map((e) => e.item2) - .toList(); -} - -bool _isSortAscending(AlbumSort sort) => - sort == AlbumSort.dateAscending || sort == AlbumSort.nameAscending; diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart new file mode 100644 index 00000000..4c205053 --- /dev/null +++ b/app/lib/entity/collection.dart @@ -0,0 +1,93 @@ +import 'package:copy_with/copy_with.dart'; +import 'package:nc_photos/entity/collection_item/sorter.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:to_string/to_string.dart'; + +part 'collection.g.dart'; + +/// Describe a group of items +@genCopyWith +@toString +class Collection { + const Collection({ + required this.name, + required this.contentProvider, + }); + + @override + String toString() => _$toString(); + + bool compareIdentity(Collection other) => other.id == id; + + int get identityHashCode => id.hashCode; + + /// A unique id for each collection. The value is divided into two parts in + /// the format XXXX-YYY...YYY, where XXXX is a four-character code + /// representing the content provider type, and YYY is an implementation + /// detail of each providers + String get id => "${contentProvider.fourCc}-${contentProvider.id}"; + + /// See [CollectionContentProvider.count] + int? get count => contentProvider.count; + + /// See [CollectionContentProvider.lastModified] + DateTime get lastModified => contentProvider.lastModified; + + /// See [CollectionContentProvider.capabilities] + List get capabilities => contentProvider.capabilities; + + /// See [CollectionContentProvider.itemSort] + CollectionItemSort get itemSort => contentProvider.itemSort; + + /// See [CollectionContentProvider.getCoverUrl] + String? getCoverUrl(int width, int height) => + contentProvider.getCoverUrl(width, height); + + CollectionSorter getSorter() => CollectionSorter.fromSortType(itemSort); + + final String name; + final CollectionContentProvider contentProvider; +} + +enum CollectionCapability { + // add/remove items + manualItem, + // sort the items + sort, + // rearrange item manually + manualSort, + // can freely rename album + rename, + // text labels + labelItem, +} + +/// Provide the actual content of a collection +abstract class CollectionContentProvider { + const CollectionContentProvider(); + + /// Unique FourCC of this provider type + String get fourCc; + + /// Return the unique id of this collection + String get id; + + /// Return the number of items in this collection, or null if not supported + int? get count; + + /// Return the date time of this collection. Generally this is the date time + /// of the latest child + DateTime get lastModified; + + /// Return the capabilities of the collection + List get capabilities; + + /// Return the sort type + CollectionItemSort get itemSort; + + /// Return the URL of the cover image if available + /// + /// The [width] and [height] are provided as a hint only, implementations are + /// free to ignore them if it's not supported + String? getCoverUrl(int width, int height); +} diff --git a/app/lib/entity/collection.g.dart b/app/lib/entity/collection.g.dart new file mode 100644 index 00000000..e388de4a --- /dev/null +++ b/app/lib/entity/collection.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'collection.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $CollectionCopyWithWorker { + Collection call({String? name, CollectionContentProvider? contentProvider}); +} + +class _$CollectionCopyWithWorkerImpl implements $CollectionCopyWithWorker { + _$CollectionCopyWithWorkerImpl(this.that); + + @override + Collection call({dynamic name, dynamic contentProvider}) { + return Collection( + name: name as String? ?? that.name, + contentProvider: contentProvider as CollectionContentProvider? ?? + that.contentProvider); + } + + final Collection that; +} + +extension $CollectionCopyWith on Collection { + $CollectionCopyWithWorker get copyWith => _$copyWith; + $CollectionCopyWithWorker get _$copyWith => + _$CollectionCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionToString on Collection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "Collection {name: $name, contentProvider: $contentProvider}"; + } +} diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart new file mode 100644 index 00000000..343291da --- /dev/null +++ b/app/lib/entity/collection/adapter.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter/album.dart'; +import 'package:nc_photos/entity/collection/adapter/location_group.dart'; +import 'package:nc_photos/entity/collection/adapter/nc_album.dart'; +import 'package:nc_photos/entity/collection/adapter/person.dart'; +import 'package:nc_photos/entity/collection/adapter/tag.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/content_provider/location_group.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/entity/collection/content_provider/person.dart'; +import 'package:nc_photos/entity/collection/content_provider/tag.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/new_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_common/type.dart'; + +abstract class CollectionAdapter { + const CollectionAdapter(); + + static CollectionAdapter of( + DiContainer c, Account account, Collection collection) { + switch (collection.contentProvider.runtimeType) { + case CollectionAlbumProvider: + return CollectionAlbumAdapter(c, account, collection); + case CollectionLocationGroupProvider: + return CollectionLocationGroupAdapter(c, account, collection); + case CollectionNcAlbumProvider: + return CollectionNcAlbumAdapter(c, account, collection); + case CollectionPersonProvider: + return CollectionPersonAdapter(c, account, collection); + case CollectionTagProvider: + return CollectionTagAdapter(c, account, collection); + default: + throw UnsupportedError( + "Unknown type: ${collection.contentProvider.runtimeType}"); + } + } + + /// List items inside this collection + Stream> listItem(); + + /// Add [files] to this collection and return the added count + Future addFiles( + List files, { + ErrorWithValueHandler? onError, + required ValueChanged onCollectionUpdated, + }); + + /// Edit this collection + /// + /// [name] and [items] are optional params and if not null, set the value to + /// this collection + Future edit({ + String? name, + List? items, + CollectionItemSort? itemSort, + }); + + /// Remove [items] from this collection and return the removed count + Future removeItems( + List items, { + ErrorWithValueIndexedHandler? onError, + required ValueChanged onCollectionUpdated, + }); + + /// Convert a [NewCollectionItem] to an adapted one + Future adaptToNewItem(NewCollectionItem original); + + bool isItemsRemovable(List items); + + /// Remove this collection + Future remove(); +} + +abstract class CollectionItemAdapter { + const CollectionItemAdapter(); + + CollectionItem toItem(); +} diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart new file mode 100644 index 00000000..26cc76c6 --- /dev/null +++ b/app/lib/entity/collection/adapter/album.dart @@ -0,0 +1,207 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/builder.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/album_item_adapter.dart'; +import 'package:nc_photos/entity/collection_item/new_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/use_case/album/add_file_to_album.dart'; +import 'package:nc_photos/use_case/album/edit_album.dart'; +import 'package:nc_photos/use_case/album/remove_album.dart'; +import 'package:nc_photos/use_case/album/remove_from_album.dart'; +import 'package:nc_photos/use_case/preprocess_album.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; +import 'package:tuple/tuple.dart'; + +part 'album.g.dart'; + +@npLog +class CollectionAlbumAdapter implements CollectionAdapter { + CollectionAlbumAdapter(this._c, this.account, this.collection) + : assert(require(_c)), + _provider = collection.contentProvider as CollectionAlbumProvider; + + static bool require(DiContainer c) => PreProcessAlbum.require(c); + + @override + Stream> listItem() async* { + final items = await PreProcessAlbum(_c)(account, _provider.album); + yield items.map((i) { + if (i is AlbumFileItem) { + return CollectionFileItemAlbumAdapter(i); + } else if (i is AlbumLabelItem) { + return CollectionLabelItemAlbumAdapter(i); + } else { + _log.shout("[listItem] Unknown item type: ${i.runtimeType}"); + throw UnimplementedError("Unknown item type: ${i.runtimeType}"); + } + }).toList(); + } + + @override + Future addFiles( + List files, { + ErrorWithValueHandler? onError, + required ValueChanged onCollectionUpdated, + }) async { + try { + final newAlbum = + await AddFileToAlbum(_c)(account, _provider.album, files); + onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum)); + return files.length; + } catch (e, stackTrace) { + for (final f in files) { + onError?.call(f, e, stackTrace); + } + return 0; + } + } + + @override + Future edit({ + String? name, + List? items, + CollectionItemSort? itemSort, + }) async { + assert(name != null || items != null || itemSort != null); + final newItems = items?.run((items) => items + .map((e) { + if (e is AlbumAdaptedCollectionItem) { + return e.albumItem; + } else if (e is NewCollectionLabelItem) { + // new labels + return AlbumLabelItem( + addedBy: account.userId, + addedAt: e.createdAt, + text: e.text, + ); + } else { + _log.severe("[edit] Unsupported type: ${e.runtimeType}"); + return null; + } + }) + .whereNotNull() + .toList()); + final newAlbum = await EditAlbum(_c)( + account, + _provider.album, + name: name, + items: newItems, + itemSort: itemSort, + ); + return collection.copyWith( + name: name, + contentProvider: _provider.copyWith(album: newAlbum), + ); + } + + @override + Future removeItems( + List items, { + ErrorWithValueIndexedHandler? onError, + required ValueChanged onCollectionUpdated, + }) async { + try { + final group = items + .withIndex() + .groupListsBy((e) => e.item2 is AlbumAdaptedCollectionItem); + var failed = 0; + if (group[true]?.isNotEmpty ?? false) { + final newAlbum = await RemoveFromAlbum(_c)( + account, + _provider.album, + group[true]! + .map((e) => e.item2) + .cast() + .map((e) => e.albumItem) + .toList(), + onError: (i, item, e, stackTrace) { + ++failed; + final actualIndex = group[true]![i].item1; + try { + onError?.call(actualIndex, items[actualIndex], e, stackTrace); + } catch (e, stackTrace) { + _log.severe("[removeItems] Unknown error", e, stackTrace); + } + }, + ); + onCollectionUpdated(collection.copyWith( + contentProvider: _provider.copyWith( + album: newAlbum, + ), + )); + } + for (final pair in (group[false] ?? const >[])) { + final actualIndex = pair.item1; + onError?.call( + actualIndex, + items[actualIndex], + UnsupportedError( + "Unsupported item type: ${items[actualIndex].runtimeType}"), + StackTrace.current, + ); + } + return (group[true] ?? []).length - failed; + } catch (e, stackTrace) { + for (final pair in items.withIndex()) { + onError?.call(pair.item1, pair.item2, e, stackTrace); + } + return 0; + } + } + + @override + Future adaptToNewItem(NewCollectionItem original) async { + if (original is NewCollectionFileItem) { + final item = AlbumStaticProvider.of(_provider.album) + .items + .whereType() + .firstWhere((e) => e.file.compareServerIdentity(original.file)); + return CollectionFileItemAlbumAdapter(item); + } else if (original is NewCollectionLabelItem) { + final item = AlbumStaticProvider.of(_provider.album) + .items + .whereType() + .sorted((a, b) => a.addedAt.compareTo(b.addedAt)) + .reversed + .firstWhere((e) => e.text == original.text); + return CollectionLabelItemAlbumAdapter(item); + } else { + throw UnsupportedError("Unsupported type: ${original.runtimeType}"); + } + } + + @override + bool isItemsRemovable(List items) { + if (_provider.album.albumFile!.isOwned(account.userId)) { + return true; + } + return items + .whereType() + .any((e) => e.albumItem.addedBy == account.userId); + } + + @override + Future remove() => RemoveAlbum(_c)(account, _provider.album); + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionAlbumProvider _provider; +} + +// class CollectionAlbumItemAdapter implements CollectionItemAdapter {} diff --git a/app/lib/entity/collection/adapter/album.g.dart b/app/lib/entity/collection/adapter/album.g.dart new file mode 100644 index 00000000..160449b6 --- /dev/null +++ b/app/lib/entity/collection/adapter/album.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'album.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionAlbumAdapterNpLog on CollectionAlbumAdapter { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.collection.adapter.album.CollectionAlbumAdapter"); +} diff --git a/app/lib/entity/collection/adapter/location_group.dart b/app/lib/entity/collection/adapter/location_group.dart new file mode 100644 index 00000000..b06fd8cf --- /dev/null +++ b/app/lib/entity/collection/adapter/location_group.dart @@ -0,0 +1,56 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/content_provider/location_group.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/basic_item.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/use_case/list_location_file.dart'; + +class CollectionLocationGroupAdapter + with CollectionReadOnlyAdapter + implements CollectionAdapter { + CollectionLocationGroupAdapter(this._c, this.account, this.collection) + : assert(require(_c)), + _provider = + collection.contentProvider as CollectionLocationGroupProvider; + + static bool require(DiContainer c) => ListLocationFile.require(c); + + @override + Stream> listItem() async* { + final files = []; + for (final r in account.roots) { + final dir = File(path: file_util.unstripPath(account, r)); + files.addAll(await ListLocationFile(_c)(account, dir, + _provider.location.place, _provider.location.countryCode)); + } + yield files + .where((f) => file_util.isSupportedFormat(f)) + .map((f) => BasicCollectionFileItem(f)) + .toList(); + } + + @override + Future adaptToNewItem(CollectionItem original) async { + if (original is CollectionFileItem) { + return BasicCollectionFileItem(original.file); + } else { + throw UnsupportedError("Unsupported type: ${original.runtimeType}"); + } + } + + @override + Future remove() { + throw UnsupportedError("Operation not supported"); + } + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionLocationGroupProvider _provider; +} diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart new file mode 100644 index 00000000..dde6bdae --- /dev/null +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -0,0 +1,151 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/basic_item.dart'; +import 'package:nc_photos/entity/collection_item/new_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/use_case/find_file_descriptor.dart'; +import 'package:nc_photos/use_case/nc_album/add_file_to_nc_album.dart'; +import 'package:nc_photos/use_case/nc_album/edit_nc_album.dart'; +import 'package:nc_photos/use_case/nc_album/list_nc_album.dart'; +import 'package:nc_photos/use_case/nc_album/list_nc_album_item.dart'; +import 'package:nc_photos/use_case/nc_album/remove_from_nc_album.dart'; +import 'package:nc_photos/use_case/nc_album/remove_nc_album.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'nc_album.g.dart'; + +@npLog +class CollectionNcAlbumAdapter implements CollectionAdapter { + CollectionNcAlbumAdapter(this._c, this.account, this.collection) + : assert(require(_c)), + _provider = collection.contentProvider as CollectionNcAlbumProvider; + + static bool require(DiContainer c) => + ListNcAlbumItem.require(c) && FindFileDescriptor.require(c); + + @override + Stream> listItem() { + return ListNcAlbumItem(_c)(account, _provider.album) + .asyncMap((items) => FindFileDescriptor(_c)( + account, + items.map((e) => e.fileId).toList(), + onFileNotFound: (fileId) { + _log.severe("[listItem] File not found: $fileId"); + }, + )) + .map((files) => files.map(BasicCollectionFileItem.new).toList()); + } + + @override + Future addFiles( + List files, { + ErrorWithValueHandler? onError, + required ValueChanged onCollectionUpdated, + }) async { + final count = await AddFileToNcAlbum(_c)(account, _provider.album, files, + onError: onError); + if (count > 0) { + try { + final newAlbum = await _syncRemote(); + onCollectionUpdated(collection.copyWith( + contentProvider: _provider.copyWith( + album: newAlbum, + ), + )); + } catch (e, stackTrace) { + _log.severe("[addFiles] Failed while _syncRemote", e, stackTrace); + } + } + return count; + } + + @override + Future edit({ + String? name, + List? items, + CollectionItemSort? itemSort, + }) async { + assert(name != null); + if (items != null || itemSort != null) { + _log.warning( + "[edit] Nextcloud album does not support editing item or sort"); + } + final newItems = items?.run((items) => items + .map((e) => e is CollectionFileItem ? e.file : null) + .whereNotNull() + .toList()); + final newAlbum = await EditNcAlbum(_c)( + account, + _provider.album, + name: name, + items: newItems, + itemSort: itemSort, + ); + return collection.copyWith( + name: name, + contentProvider: _provider.copyWith(album: newAlbum), + ); + } + + @override + Future removeItems( + List items, { + ErrorWithValueIndexedHandler? onError, + required ValueChanged onCollectionUpdated, + }) async { + final count = await RemoveFromNcAlbum(_c)(account, _provider.album, items, + onError: onError); + if (count > 0) { + try { + final newAlbum = await _syncRemote(); + onCollectionUpdated(collection.copyWith( + contentProvider: _provider.copyWith( + album: newAlbum, + ), + )); + } catch (e, stackTrace) { + _log.severe("[removeItems] Failed while _syncRemote", e, stackTrace); + } + } + return count; + } + + @override + Future adaptToNewItem(NewCollectionItem original) async { + if (original is NewCollectionFileItem) { + return BasicCollectionFileItem(original.file); + } else { + throw UnsupportedError("Unsupported type: ${original.runtimeType}"); + } + } + + @override + bool isItemsRemovable(List items) { + return true; + } + + @override + Future remove() => RemoveNcAlbum(_c)(account, _provider.album); + + Future _syncRemote() async { + final remote = await ListNcAlbum(_c)(account).last; + return remote.firstWhere((e) => e.compareIdentity(_provider.album)); + } + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionNcAlbumProvider _provider; +} diff --git a/app/lib/entity/collection/adapter/nc_album.g.dart b/app/lib/entity/collection/adapter/nc_album.g.dart new file mode 100644 index 00000000..ea28502e --- /dev/null +++ b/app/lib/entity/collection/adapter/nc_album.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nc_album.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionNcAlbumAdapterNpLog on CollectionNcAlbumAdapter { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.collection.adapter.nc_album.CollectionNcAlbumAdapter"); +} diff --git a/app/lib/entity/collection/adapter/person.dart b/app/lib/entity/collection/adapter/person.dart new file mode 100644 index 00000000..0d9ee9c0 --- /dev/null +++ b/app/lib/entity/collection/adapter/person.dart @@ -0,0 +1,58 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/content_provider/person.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/basic_item.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/use_case/list_face.dart'; +import 'package:nc_photos/use_case/populate_person.dart'; + +class CollectionPersonAdapter + with CollectionReadOnlyAdapter + implements CollectionAdapter { + CollectionPersonAdapter(this._c, this.account, this.collection) + : assert(require(_c)), + _provider = collection.contentProvider as CollectionPersonProvider; + + static bool require(DiContainer c) => + ListFace.require(c) && PopulatePerson.require(c); + + @override + Stream> listItem() async* { + final faces = await ListFace(_c)(account, _provider.person); + final files = await PopulatePerson(_c)(account, faces); + final rootDirs = account.roots + .map((e) => File(path: file_util.unstripPath(account, e))) + .toList(); + yield files + .where((f) => + file_util.isSupportedFormat(f) && + rootDirs.any((dir) => file_util.isUnderDir(f, dir))) + .map((f) => BasicCollectionFileItem(f)) + .toList(); + } + + @override + Future adaptToNewItem(CollectionItem original) async { + if (original is CollectionFileItem) { + return BasicCollectionFileItem(original.file); + } else { + throw UnsupportedError("Unsupported type: ${original.runtimeType}"); + } + } + + @override + Future remove() { + throw UnsupportedError("Operation not supported"); + } + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionPersonProvider _provider; +} diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/read_only_adapter.dart new file mode 100644 index 00000000..af68d849 --- /dev/null +++ b/app/lib/entity/collection/adapter/read_only_adapter.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_common/type.dart'; + +/// A read-only collection that does not support modifying its items +mixin CollectionReadOnlyAdapter implements CollectionAdapter { + @override + Future addFiles( + List files, { + ErrorWithValueHandler? onError, + required ValueChanged onCollectionUpdated, + }) { + throw UnsupportedError("Operation not supported"); + } + + @override + Future edit({ + String? name, + List? items, + CollectionItemSort? itemSort, + }) { + throw UnsupportedError("Operation not supported"); + } + + @override + Future removeItems( + List items, { + ErrorWithValueIndexedHandler? onError, + required ValueChanged onCollectionUpdated, + }) { + throw UnsupportedError("Operation not supported"); + } + + @override + bool isItemsRemovable(List items) { + return false; + } +} diff --git a/app/lib/entity/collection/adapter/tag.dart b/app/lib/entity/collection/adapter/tag.dart new file mode 100644 index 00000000..e7a85432 --- /dev/null +++ b/app/lib/entity/collection/adapter/tag.dart @@ -0,0 +1,45 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/content_provider/tag.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/basic_item.dart'; +import 'package:nc_photos/use_case/list_tagged_file.dart'; + +class CollectionTagAdapter + with CollectionReadOnlyAdapter + implements CollectionAdapter { + CollectionTagAdapter(this._c, this.account, this.collection) + : assert(require(_c)), + _provider = collection.contentProvider as CollectionTagProvider; + + static bool require(DiContainer c) => true; + + @override + Stream> listItem() async* { + final files = await ListTaggedFile(_c)(account, _provider.tags); + yield files.map((f) => BasicCollectionFileItem(f)).toList(); + } + + @override + Future adaptToNewItem(CollectionItem original) async { + if (original is CollectionFileItem) { + return BasicCollectionFileItem(original.file); + } else { + throw UnsupportedError("Unsupported type: ${original.runtimeType}"); + } + } + + @override + Future remove() { + throw UnsupportedError("Operation not supported"); + } + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionTagProvider _provider; +} diff --git a/app/lib/entity/collection/builder.dart b/app/lib/entity/collection/builder.dart new file mode 100644 index 00000000..403fa313 --- /dev/null +++ b/app/lib/entity/collection/builder.dart @@ -0,0 +1,64 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/content_provider/location_group.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/entity/collection/content_provider/person.dart'; +import 'package:nc_photos/entity/collection/content_provider/tag.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/entity/tag.dart'; +import 'package:nc_photos/use_case/list_location_group.dart'; + +class CollectionBuilder { + static Collection byAlbum(Account account, Album album) { + return Collection( + name: album.name, + contentProvider: CollectionAlbumProvider( + account: account, + album: album, + ), + ); + } + + static Collection byLocationGroup(Account account, LocationGroup location) { + return Collection( + name: location.place, + contentProvider: CollectionLocationGroupProvider( + account: account, + location: location, + ), + ); + } + + static Collection byNcAlbum(Account account, NcAlbum album) { + return Collection( + name: album.strippedPath, + contentProvider: CollectionNcAlbumProvider( + account: account, + album: album, + ), + ); + } + + static Collection byPerson(Account account, Person person) { + return Collection( + name: person.name, + contentProvider: CollectionPersonProvider( + account: account, + person: person, + ), + ); + } + + static Collection byTags(Account account, List tags) { + return Collection( + name: tags.first.displayName, + contentProvider: CollectionTagProvider( + account: account, + tags: tags, + ), + ); + } +} diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart new file mode 100644 index 00000000..a6e06e4f --- /dev/null +++ b/app/lib/entity/collection/content_provider/album.dart @@ -0,0 +1,75 @@ +import 'package:copy_with/copy_with.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:to_string/to_string.dart'; + +part 'album.g.dart'; + +/// Album provided by our app +@genCopyWith +@toString +class CollectionAlbumProvider implements CollectionContentProvider { + const CollectionAlbumProvider({ + required this.account, + required this.album, + }); + + @override + String toString() => _$toString(); + + @override + String get fourCc => "ALBM"; + + @override + String get id => album.albumFile!.fileId!.toString(); + + @override + int? get count { + if (album.provider is AlbumStaticProvider) { + return (album.provider as AlbumStaticProvider).items.length; + } else { + return null; + } + } + + @override + DateTime get lastModified => + album.provider.latestItemTime ?? album.lastUpdated; + + @override + List get capabilities => [ + if (album.provider is AlbumStaticProvider) ...[ + CollectionCapability.manualItem, + CollectionCapability.sort, + CollectionCapability.manualSort, + CollectionCapability.rename, + CollectionCapability.labelItem, + ], + ]; + + @override + CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort(); + + @override + String? getCoverUrl(int width, int height) { + final fd = album.coverProvider.getCover(album); + if (fd == null) { + return null; + } else { + return api_util.getFilePreviewUrlByFileId( + account, + fd.fdId, + width: width, + height: height, + isKeepAspectRatio: false, + ); + } + } + + final Account account; + final Album album; +} diff --git a/app/lib/entity/collection/content_provider/album.g.dart b/app/lib/entity/collection/content_provider/album.g.dart new file mode 100644 index 00000000..0b0d59d0 --- /dev/null +++ b/app/lib/entity/collection/content_provider/album.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'album.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $CollectionAlbumProviderCopyWithWorker { + CollectionAlbumProvider call({Account? account, Album? album}); +} + +class _$CollectionAlbumProviderCopyWithWorkerImpl + implements $CollectionAlbumProviderCopyWithWorker { + _$CollectionAlbumProviderCopyWithWorkerImpl(this.that); + + @override + CollectionAlbumProvider call({dynamic account, dynamic album}) { + return CollectionAlbumProvider( + account: account as Account? ?? that.account, + album: album as Album? ?? that.album); + } + + final CollectionAlbumProvider that; +} + +extension $CollectionAlbumProviderCopyWith on CollectionAlbumProvider { + $CollectionAlbumProviderCopyWithWorker get copyWith => _$copyWith; + $CollectionAlbumProviderCopyWithWorker get _$copyWith => + _$CollectionAlbumProviderCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionAlbumProviderToString on CollectionAlbumProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionAlbumProvider {account: $account, album: $album}"; + } +} diff --git a/app/lib/entity/collection/content_provider/location_group.dart b/app/lib/entity/collection/content_provider/location_group.dart new file mode 100644 index 00000000..ff96e1a0 --- /dev/null +++ b/app/lib/entity/collection/content_provider/location_group.dart @@ -0,0 +1,44 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/use_case/list_location_group.dart'; + +class CollectionLocationGroupProvider implements CollectionContentProvider { + const CollectionLocationGroupProvider({ + required this.account, + required this.location, + }); + + @override + String get fourCc => "LOCG"; + + @override + String get id => location.place; + + @override + int? get count => location.count; + + @override + DateTime get lastModified => location.latestDateTime; + + @override + List get capabilities => []; + + @override + CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + + @override + String? getCoverUrl(int width, int height) { + return api_util.getFilePreviewUrlByFileId( + account, + location.latestFileId, + width: width, + height: height, + isKeepAspectRatio: false, + ); + } + + final Account account; + final LocationGroup location; +} diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart new file mode 100644 index 00000000..5d537cea --- /dev/null +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -0,0 +1,62 @@ +import 'package:clock/clock.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:to_string/to_string.dart'; + +part 'nc_album.g.dart'; + +/// Album provided by our app +@genCopyWith +@toString +class CollectionNcAlbumProvider implements CollectionContentProvider { + const CollectionNcAlbumProvider({ + required this.account, + required this.album, + }); + + @override + String toString() => _$toString(); + + @override + String get fourCc => "NC25"; + + @override + String get id => album.path; + + @override + int? get count => album.count; + + @override + DateTime get lastModified => album.dateEnd ?? clock.now().toUtc(); + + @override + List get capabilities => [ + CollectionCapability.manualItem, + CollectionCapability.rename, + ]; + + @override + CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + + @override + String? getCoverUrl(int width, int height) { + if (album.lastPhoto == null) { + return null; + } else { + return api_util.getFilePreviewUrlByFileId( + account, + album.lastPhoto!, + width: width, + height: height, + isKeepAspectRatio: false, + ); + } + } + + final Account account; + final NcAlbum album; +} diff --git a/app/lib/entity/collection/content_provider/nc_album.g.dart b/app/lib/entity/collection/content_provider/nc_album.g.dart new file mode 100644 index 00000000..bd2e9f26 --- /dev/null +++ b/app/lib/entity/collection/content_provider/nc_album.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nc_album.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $CollectionNcAlbumProviderCopyWithWorker { + CollectionNcAlbumProvider call({Account? account, NcAlbum? album}); +} + +class _$CollectionNcAlbumProviderCopyWithWorkerImpl + implements $CollectionNcAlbumProviderCopyWithWorker { + _$CollectionNcAlbumProviderCopyWithWorkerImpl(this.that); + + @override + CollectionNcAlbumProvider call({dynamic account, dynamic album}) { + return CollectionNcAlbumProvider( + account: account as Account? ?? that.account, + album: album as NcAlbum? ?? that.album); + } + + final CollectionNcAlbumProvider that; +} + +extension $CollectionNcAlbumProviderCopyWith on CollectionNcAlbumProvider { + $CollectionNcAlbumProviderCopyWithWorker get copyWith => _$copyWith; + $CollectionNcAlbumProviderCopyWithWorker get _$copyWith => + _$CollectionNcAlbumProviderCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionNcAlbumProviderToString on CollectionNcAlbumProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionNcAlbumProvider {account: $account, album: $album}"; + } +} diff --git a/app/lib/entity/collection/content_provider/person.dart b/app/lib/entity/collection/content_provider/person.dart new file mode 100644 index 00000000..ef3ce1cd --- /dev/null +++ b/app/lib/entity/collection/content_provider/person.dart @@ -0,0 +1,42 @@ +import 'dart:math' as math; + +import 'package:clock/clock.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/person.dart'; + +class CollectionPersonProvider implements CollectionContentProvider { + const CollectionPersonProvider({ + required this.account, + required this.person, + }); + + @override + String get fourCc => "PERS"; + + @override + String get id => person.name; + + @override + int? get count => person.count; + + @override + DateTime get lastModified => clock.now().toUtc(); + + @override + List get capabilities => []; + + @override + CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + + @override + String? getCoverUrl(int width, int height) { + return api_util.getFacePreviewUrl(account, person.thumbFaceId, + size: math.max(width, height)); + } + + final Account account; + final Person person; +} diff --git a/app/lib/entity/collection/content_provider/tag.dart b/app/lib/entity/collection/content_provider/tag.dart new file mode 100644 index 00000000..ab4b415e --- /dev/null +++ b/app/lib/entity/collection/content_provider/tag.dart @@ -0,0 +1,36 @@ +import 'package:clock/clock.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/tag.dart'; + +class CollectionTagProvider implements CollectionContentProvider { + CollectionTagProvider({ + required this.account, + required this.tags, + }) : assert(tags.isNotEmpty); + + @override + String get fourCc => "TAG-"; + + @override + String get id => tags.first.displayName; + + @override + int? get count => null; + + @override + DateTime get lastModified => clock.now().toUtc(); + + @override + List get capabilities => []; + + @override + CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + + @override + String? getCoverUrl(int width, int height) => null; + + final Account account; + final List tags; +} diff --git a/app/lib/entity/collection/util.dart b/app/lib/entity/collection/util.dart new file mode 100644 index 00000000..3370908a --- /dev/null +++ b/app/lib/entity/collection/util.dart @@ -0,0 +1,43 @@ +import 'package:collection/collection.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:tuple/tuple.dart'; + +enum CollectionSort { + dateDescending, + dateAscending, + nameAscending, + nameDescending; + + bool isAscending() { + return this == CollectionSort.dateAscending || + this == CollectionSort.nameAscending; + } +} + +extension CollectionListExtension on List { + List sortedBy(CollectionSort by) { + return map>((e) { + switch (by) { + case CollectionSort.nameAscending: + case CollectionSort.nameDescending: + return Tuple2(e.name.toLowerCase(), e); + + case CollectionSort.dateAscending: + case CollectionSort.dateDescending: + return Tuple2(e.contentProvider.lastModified, e); + } + }) + .sorted((a, b) { + final x = by.isAscending() ? a : b; + final y = by.isAscending() ? b : a; + final tmp = x.item1.compareTo(y.item1); + if (tmp != 0) { + return tmp; + } else { + return x.item2.name.compareTo(y.item2.name); + } + }) + .map((e) => e.item2) + .toList(); + } +} diff --git a/app/lib/entity/collection_item.dart b/app/lib/entity/collection_item.dart new file mode 100644 index 00000000..376c66b0 --- /dev/null +++ b/app/lib/entity/collection_item.dart @@ -0,0 +1,22 @@ +import 'package:nc_photos/entity/file_descriptor.dart'; + +/// An item in a [Collection] +abstract class CollectionItem { + const CollectionItem(); +} + +abstract class CollectionFileItem implements CollectionItem { + const CollectionFileItem(); + + FileDescriptor get file; +} + +abstract class CollectionLabelItem implements CollectionItem { + const CollectionLabelItem(); + + /// An object used to identify this instance + /// + /// [id] should be unique and stable + Object get id; + String get text; +} diff --git a/app/lib/entity/collection_item/album_item_adapter.dart b/app/lib/entity/collection_item/album_item_adapter.dart new file mode 100644 index 00000000..8071c359 --- /dev/null +++ b/app/lib/entity/collection_item/album_item_adapter.dart @@ -0,0 +1,57 @@ +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:to_string/to_string.dart'; + +part 'album_item_adapter.g.dart'; + +mixin AlbumAdaptedCollectionItem on CollectionItem { + static AlbumAdaptedCollectionItem fromItem(AlbumItem item) { + if (item is AlbumFileItem) { + return CollectionFileItemAlbumAdapter(item); + } else if (item is AlbumLabelItem) { + return CollectionLabelItemAlbumAdapter(item); + } else { + throw ArgumentError("Unknown type: ${item.runtimeType}"); + } + } + + AlbumItem get albumItem; +} + +@toString +class CollectionFileItemAlbumAdapter extends CollectionFileItem + with AlbumAdaptedCollectionItem { + const CollectionFileItemAlbumAdapter(this.item); + + @override + String toString() => _$toString(); + + @override + FileDescriptor get file => item.file; + + @override + AlbumItem get albumItem => item; + + final AlbumFileItem item; +} + +@toString +class CollectionLabelItemAlbumAdapter extends CollectionLabelItem + with AlbumAdaptedCollectionItem { + const CollectionLabelItemAlbumAdapter(this.item); + + @override + String toString() => _$toString(); + + @override + Object get id => item.addedAt; + + @override + String get text => item.text; + + @override + AlbumItem get albumItem => item; + + final AlbumLabelItem item; +} diff --git a/app/lib/entity/collection_item/album_item_adapter.g.dart b/app/lib/entity/collection_item/album_item_adapter.g.dart new file mode 100644 index 00000000..18769b88 --- /dev/null +++ b/app/lib/entity/collection_item/album_item_adapter.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'album_item_adapter.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionFileItemAlbumAdapterToString + on CollectionFileItemAlbumAdapter { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionFileItemAlbumAdapter {item: $item}"; + } +} + +extension _$CollectionLabelItemAlbumAdapterToString + on CollectionLabelItemAlbumAdapter { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionLabelItemAlbumAdapter {item: $item}"; + } +} diff --git a/app/lib/entity/collection_item/basic_item.dart b/app/lib/entity/collection_item/basic_item.dart new file mode 100644 index 00000000..3411264e --- /dev/null +++ b/app/lib/entity/collection_item/basic_item.dart @@ -0,0 +1,17 @@ +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:to_string/to_string.dart'; + +part 'basic_item.g.dart'; + +/// The basic form of [CollectionFileItem] +@toString +class BasicCollectionFileItem implements CollectionFileItem { + const BasicCollectionFileItem(this.file); + + @override + String toString() => _$toString(); + + @override + final FileDescriptor file; +} diff --git a/app/lib/entity/collection_item/basic_item.g.dart b/app/lib/entity/collection_item/basic_item.g.dart new file mode 100644 index 00000000..0ada892e --- /dev/null +++ b/app/lib/entity/collection_item/basic_item.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'basic_item.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$BasicCollectionFileItemToString on BasicCollectionFileItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "BasicCollectionFileItem {file: ${file.fdPath}}"; + } +} diff --git a/app/lib/entity/collection_item/new_item.dart b/app/lib/entity/collection_item/new_item.dart new file mode 100644 index 00000000..efd4e95a --- /dev/null +++ b/app/lib/entity/collection_item/new_item.dart @@ -0,0 +1,42 @@ +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:to_string/to_string.dart'; + +part 'new_item.g.dart'; + +abstract class NewCollectionItem implements CollectionItem {} + +/// A new [CollectionFileItem] +/// +/// This class is for marking an intermediate item that has recently been added +/// but not necessarily persisted yet to the provider of this collection +@toString +class NewCollectionFileItem implements CollectionFileItem, NewCollectionItem { + const NewCollectionFileItem(this.file); + + @override + String toString() => _$toString(); + + @override + final FileDescriptor file; +} + +/// A new [CollectionLabelItem] +/// +/// This class is for marking an intermediate item that has recently been added +/// but not necessarily persisted yet to the provider of this collection +@toString +class NewCollectionLabelItem implements CollectionLabelItem, NewCollectionItem { + const NewCollectionLabelItem(this.text, this.createdAt); + + @override + String toString() => _$toString(); + + @override + Object get id => createdAt; + + @override + final String text; + + final DateTime createdAt; +} diff --git a/app/lib/entity/collection_item/new_item.g.dart b/app/lib/entity/collection_item/new_item.g.dart new file mode 100644 index 00000000..b22ab8c7 --- /dev/null +++ b/app/lib/entity/collection_item/new_item.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'new_item.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$NewCollectionFileItemToString on NewCollectionFileItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NewCollectionFileItem {file: ${file.fdPath}}"; + } +} + +extension _$NewCollectionLabelItemToString on NewCollectionLabelItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NewCollectionLabelItem {text: $text, createdAt: $createdAt}"; + } +} diff --git a/app/lib/entity/collection_item/sorter.dart b/app/lib/entity/collection_item/sorter.dart new file mode 100644 index 00000000..346d517a --- /dev/null +++ b/app/lib/entity/collection_item/sorter.dart @@ -0,0 +1,148 @@ +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:tuple/tuple.dart'; + +part 'sorter.g.dart'; + +abstract class CollectionSorter { + const CollectionSorter(); + + static CollectionSorter fromSortType(CollectionItemSort type) { + switch (type) { + case CollectionItemSort.dateDescending: + return const CollectionTimeSorter(isAscending: false); + case CollectionItemSort.dateAscending: + return const CollectionTimeSorter(isAscending: true); + case CollectionItemSort.nameAscending: + return const CollectionFilenameSorter(isAscending: true); + case CollectionItemSort.nameDescending: + return const CollectionFilenameSorter(isAscending: false); + case CollectionItemSort.manual: + return const CollectionNullSorter(); + } + } + + /// Return a sorted copy of [items] + List call(List items); +} + +/// Sort provider that does nothing +class CollectionNullSorter implements CollectionSorter { + const CollectionNullSorter(); + + @override + List call(List items) { + return List.of(items); + } +} + +/// Sort based on the time of the files +class CollectionTimeSorter implements CollectionSorter { + const CollectionTimeSorter({ + required this.isAscending, + }); + + @override + List call(List items) { + DateTime? prevFileTime; + return items + .map((e) { + if (e is CollectionFileItem) { + // take the file time + prevFileTime = e.file.fdDateTime; + } + // for non file items, use the sibling file's time + return Tuple2(prevFileTime, e); + }) + .stableSorted((x, y) { + if (x.item1 == null && y.item1 == null) { + return 0; + } else if (x.item1 == null) { + return -1; + } else if (y.item1 == null) { + return 1; + } else { + if (isAscending) { + return x.item1!.compareTo(y.item1!); + } else { + return y.item1!.compareTo(x.item1!); + } + } + }) + .map((e) => e.item2) + .toList(); + } + + final bool isAscending; +} + +/// Sort based on the name of the files +class CollectionFilenameSorter implements CollectionSorter { + const CollectionFilenameSorter({ + required this.isAscending, + }); + + @override + List call(List items) { + String? prevFilename; + return items + .map((e) { + if (e is CollectionFileItem) { + // take the file name + prevFilename = e.file.filename; + } + // for non file items, use the sibling file's name + return Tuple2(prevFilename, e); + }) + .stableSorted((x, y) { + if (x.item1 == null && y.item1 == null) { + return 0; + } else if (x.item1 == null) { + return -1; + } else if (y.item1 == null) { + return 1; + } else { + if (isAscending) { + return compareNatural(x.item1!, y.item1!); + } else { + return compareNatural(y.item1!, x.item1!); + } + } + }) + .map((e) => e.item2) + .toList(); + } + + final bool isAscending; +} + +@npLog +class CollectionAlbumSortAdapter implements CollectionSorter { + const CollectionAlbumSortAdapter(this.sort); + + @override + List call(List items) { + final CollectionSorter sorter; + if (sort is AlbumNullSortProvider) { + sorter = const CollectionNullSorter(); + } else if (sort is AlbumTimeSortProvider) { + sorter = CollectionTimeSorter( + isAscending: (sort as AlbumTimeSortProvider).isAscending); + } else if (sort is AlbumFilenameSortProvider) { + sorter = CollectionFilenameSorter( + isAscending: (sort as AlbumFilenameSortProvider).isAscending); + } else { + _log.shout("[call] Unknown type: ${sort.runtimeType}"); + throw UnsupportedError("Unknown type: ${sort.runtimeType}"); + } + return sorter(items); + } + + final AlbumSortProvider sort; +} diff --git a/app/lib/entity/collection_item/sorter.g.dart b/app/lib/entity/collection_item/sorter.g.dart new file mode 100644 index 00000000..d28f5460 --- /dev/null +++ b/app/lib/entity/collection_item/sorter.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sorter.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionAlbumSortAdapterNpLog on CollectionAlbumSortAdapter { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.collection_item.sorter.CollectionAlbumSortAdapter"); +} diff --git a/app/lib/entity/collection_item/util.dart b/app/lib/entity/collection_item/util.dart new file mode 100644 index 00000000..8eed45fc --- /dev/null +++ b/app/lib/entity/collection_item/util.dart @@ -0,0 +1,7 @@ +enum CollectionItemSort { + dateDescending, + dateAscending, + nameAscending, + nameDescending, + manual; +} diff --git a/app/lib/entity/file.dart b/app/lib/entity/file.dart index 56a2aef3..58838f50 100644 --- a/app/lib/entity/file.dart +++ b/app/lib/entity/file.dart @@ -575,7 +575,7 @@ class FileRepo { dataSrc.listMinimal(account, dir); /// See [FileDataSource.remove] - Future remove(Account account, File file) => + Future remove(Account account, FileDescriptor file) => dataSrc.remove(account, file); /// See [FileDataSource.getBinary] @@ -659,7 +659,7 @@ abstract class FileDataSource { Future> listMinimal(Account account, File dir); /// Remove file - Future remove(Account account, File f); + Future remove(Account account, FileDescriptor f); /// Read file as binary array Future getBinary(Account account, File f); diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 9121972e..5a76f483 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -90,10 +90,10 @@ class FileWebdavDataSource implements FileDataSource { } @override - remove(Account account, File f) async { - _log.info("[remove] ${f.path}"); + remove(Account account, FileDescriptor f) async { + _log.info("[remove] ${f.fdPath}"); final response = - await ApiUtil.fromAccount(account).files().delete(path: f.path); + await ApiUtil.fromAccount(account).files().delete(path: f.fdPath); if (!response.isGood) { _log.severe("[remove] Failed requesting server: $response"); throw ApiException( @@ -435,8 +435,8 @@ class FileSqliteDbDataSource implements FileDataSource { } @override - remove(Account account, File f) { - _log.info("[remove] ${f.path}"); + remove(Account account, FileDescriptor f) { + _log.info("[remove] ${f.fdPath}"); return FileSqliteCacheRemover(_c)(account, f); } @@ -719,7 +719,7 @@ class FileCachedDataSource implements FileDataSource { } @override - remove(Account account, File f) async { + remove(Account account, FileDescriptor f) async { await _remoteSrc.remove(account, f); try { await _sqliteDbSrc.remove(account, f); diff --git a/app/lib/entity/file/file_cache_manager.dart b/app/lib/entity/file/file_cache_manager.dart index b2baf4e3..2dfc4a86 100644 --- a/app/lib/entity/file/file_cache_manager.dart +++ b/app/lib/entity/file/file_cache_manager.dart @@ -359,7 +359,7 @@ class FileSqliteCacheRemover { static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); /// Remove a file/dir from cache - Future call(Account account, File f) async { + Future call(Account account, FileDescriptor f) async { await _c.sqliteDb.use((db) async { final dbAccount = await db.accountOf(account); final rowIds = await db.accountFileRowIdsOf(f, sqlAccount: dbAccount); diff --git a/app/lib/entity/file_descriptor.dart b/app/lib/entity/file_descriptor.dart index c2b2c047..eb66bae6 100644 --- a/app/lib/entity/file_descriptor.dart +++ b/app/lib/entity/file_descriptor.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:nc_photos/entity/file.dart'; import 'package:np_common/type.dart'; import 'package:path/path.dart' as path_lib; @@ -107,4 +108,14 @@ extension FileDescriptorExtension on FileDescriptor { /// hashCode to be used with [compareServerIdentity] int get identityHashCode => fdId.hashCode; + + File toFile() { + return File( + path: fdPath, + fileId: fdId, + contentType: fdMime, + isArchived: fdIsArchived, + isFavorite: fdIsFavorite, + ); + } } diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 5741c3ca..3551803e 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -20,8 +20,11 @@ bool isSupportedImageMime(String mime) => bool isSupportedImageFormat(FileDescriptor file) => isSupportedImageMime(file.fdMime ?? ""); +bool isSupportedVideoMime(String mime) => + supportedVideoFormatMimes.contains(mime); + bool isSupportedVideoFormat(FileDescriptor file) => - isSupportedFormat(file) && file.fdMime?.startsWith("video/") == true; + isSupportedVideoMime(file.fdMime ?? ""); bool isMetadataSupportedMime(String mime) => _metadataSupportedFormatMimes.contains(mime); @@ -32,21 +35,22 @@ bool isMetadataSupportedFormat(FileDescriptor file) => bool isTrash(Account account, FileDescriptor file) => file.fdPath.startsWith(api_util.getTrashbinPath(account)); -bool isAlbumFile(Account account, File file) => - file.path.startsWith(remote_storage_util.getRemoteAlbumsDir(account)); +bool isAlbumFile(Account account, FileDescriptor file) => + file.fdPath.startsWith(remote_storage_util.getRemoteAlbumsDir(account)); /// Return if [file] is located under [dir] /// /// Return false if [file] is [dir] itself (since it's not "under") /// /// See [isOrUnderDir] -bool isUnderDir(File file, File dir) => file.path.startsWith("${dir.path}/"); +bool isUnderDir(FileDescriptor file, FileDescriptor dir) => + file.fdPath.startsWith("${dir.fdPath}/"); /// Return if [file] is [dir] or located under [dir] /// /// See [isUnderDir] -bool isOrUnderDir(File file, File dir) => - file.path == dir.path || isUnderDir(file, dir); +bool isOrUnderDir(FileDescriptor file, FileDescriptor dir) => + file.fdPath == dir.fdPath || isUnderDir(file, dir); /// Convert a stripped path to a full path /// @@ -118,6 +122,9 @@ final supportedFormatMimes = [ final supportedImageFormatMimes = supportedFormatMimes.where((f) => f.startsWith("image/")).toList(); +final supportedVideoFormatMimes = + supportedFormatMimes.where((f) => f.startsWith("video/")).toList(); + const _metadataSupportedFormatMimes = [ "image/jpeg", "image/heic", diff --git a/app/lib/entity/nc_album.dart b/app/lib/entity/nc_album.dart new file mode 100644 index 00000000..82812b06 --- /dev/null +++ b/app/lib/entity/nc_album.dart @@ -0,0 +1,100 @@ +import 'package:copy_with/copy_with.dart'; +import 'package:nc_photos/account.dart'; +import 'package:np_common/string_extension.dart'; +import 'package:to_string/to_string.dart'; + +part 'nc_album.g.dart'; + +/// Server-side album since Nextcloud 25 +@toString +@genCopyWith +class NcAlbum { + NcAlbum({ + required String path, + required this.lastPhoto, + required this.nbItems, + required this.location, + required this.dateStart, + required this.dateEnd, + required this.collaborators, + }) : path = path.trimAny("/"); + + static NcAlbum createNew({ + required Account account, + required String name, + }) { + return NcAlbum( + path: "remote.php/dav/photos/${account.userId}/albums/$name", + lastPhoto: null, + nbItems: 0, + location: null, + dateStart: null, + dateEnd: null, + collaborators: const [], + ); + } + + @override + String toString() => _$toString(); + + final String path; + + /// File ID of the last photo + /// + /// The API will return -1 if there's no photos in the album. It's mapped to + /// null here instead + final int? lastPhoto; + + /// Items count + final int nbItems; + final String? location; + final DateTime? dateStart; + final DateTime? dateEnd; + final List collaborators; +} + +extension NcAlbumExtension on NcAlbum { + /// Return the path of this file with the DAV part stripped + /// + /// WebDAV file path: remote.php/dav/photos/{userId}/albums/{strippedPath}. + /// If this path points to the user's root album path, return "." + String get strippedPath { + if (!path.startsWith("remote.php/dav/photos/")) { + return path; + } + var begin = "remote.php/dav/photos/".length; + begin = path.indexOf("/", begin); + if (begin == -1) { + return path; + } + if (path.slice(begin, begin + 7) != "/albums") { + return path; + } + // /albums/ + begin += 8; + final stripped = path.slice(begin); + if (stripped.isEmpty) { + return "."; + } else { + return stripped; + } + } + + String getRenamedPath(String newName) { + final i = path.indexOf("albums/"); + if (i == -1) { + throw StateError("Invalid path: $path"); + } + return "${path.substring(0, i + "albums/".length)}$newName"; + } + + int get count => nbItems; + + bool compareIdentity(NcAlbum other) { + return path == other.path; + } + + int get identityHashCode => path.hashCode; +} + +class NcAlbumCollaborator {} diff --git a/app/lib/entity/nc_album.g.dart b/app/lib/entity/nc_album.g.dart new file mode 100644 index 00000000..727ad4a6 --- /dev/null +++ b/app/lib/entity/nc_album.g.dart @@ -0,0 +1,69 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nc_album.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $NcAlbumCopyWithWorker { + NcAlbum call( + {String? path, + int? lastPhoto, + int? nbItems, + String? location, + DateTime? dateStart, + DateTime? dateEnd, + List? collaborators}); +} + +class _$NcAlbumCopyWithWorkerImpl implements $NcAlbumCopyWithWorker { + _$NcAlbumCopyWithWorkerImpl(this.that); + + @override + NcAlbum call( + {dynamic path, + dynamic lastPhoto = copyWithNull, + dynamic nbItems, + dynamic location = copyWithNull, + dynamic dateStart = copyWithNull, + dynamic dateEnd = copyWithNull, + dynamic collaborators}) { + return NcAlbum( + path: path as String? ?? that.path, + lastPhoto: + lastPhoto == copyWithNull ? that.lastPhoto : lastPhoto as int?, + nbItems: nbItems as int? ?? that.nbItems, + location: + location == copyWithNull ? that.location : location as String?, + dateStart: + dateStart == copyWithNull ? that.dateStart : dateStart as DateTime?, + dateEnd: dateEnd == copyWithNull ? that.dateEnd : dateEnd as DateTime?, + collaborators: + collaborators as List? ?? that.collaborators); + } + + final NcAlbum that; +} + +extension $NcAlbumCopyWith on NcAlbum { + $NcAlbumCopyWithWorker get copyWith => _$copyWith; + $NcAlbumCopyWithWorker get _$copyWith => _$NcAlbumCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$NcAlbumToString on NcAlbum { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NcAlbum {path: $path, lastPhoto: $lastPhoto, nbItems: $nbItems, location: $location, dateStart: $dateStart, dateEnd: $dateEnd, collaborators: [length: ${collaborators.length}]}"; + } +} diff --git a/app/lib/entity/nc_album/data_source.dart b/app/lib/entity/nc_album/data_source.dart new file mode 100644 index 00000000..85a5b4e3 --- /dev/null +++ b/app/lib/entity/nc_album/data_source.dart @@ -0,0 +1,241 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/entity_converter.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/entity/nc_album/item.dart'; +import 'package:nc_photos/entity/nc_album/repo.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; +import 'package:nc_photos/entity/sqlite/type_converter.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/list_util.dart' as list_util; +import 'package:nc_photos/np_api_util.dart'; +import 'package:np_api/np_api.dart' as api; +import 'package:np_codegen/np_codegen.dart'; + +part 'data_source.g.dart'; + +@npLog +class NcAlbumRemoteDataSource implements NcAlbumDataSource { + const NcAlbumRemoteDataSource(); + + @override + Future> getAlbums(Account account) async { + _log.info("[getAlbums] account: ${account.userId}"); + final response = await ApiUtil.fromAccount(account) + .photos(account.userId.toString()) + .albums() + .propfind( + lastPhoto: 1, + nbItems: 1, + location: 1, + dateRange: 1, + collaborators: 1, + ); + if (!response.isGood) { + _log.severe("[getAlbums] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Server responed with an error: HTTP ${response.statusCode}", + ); + } + + final apiNcAlbums = await api.NcAlbumParser().parse(response.body); + return apiNcAlbums + .map(ApiNcAlbumConverter.fromApi) + .where((a) => a.strippedPath != ".") + .toList(); + } + + @override + Future create(Account account, NcAlbum album) async { + _log.info("[create] account: ${account.userId}, album: ${album.path}"); + final response = await ApiUtil.fromAccount(account) + .photos(account.userId.toString()) + .album(album.strippedPath) + .mkcol(); + if (!response.isGood) { + _log.severe("[create] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Server responed with an error: HTTP ${response.statusCode}", + ); + } + } + + @override + Future remove(Account account, NcAlbum album) async { + _log.info("[remove] account: ${account.userId}, album: ${album.path}"); + final response = await ApiUtil.fromAccount(account) + .photos(account.userId.toString()) + .album(album.strippedPath) + .delete(); + if (!response.isGood) { + _log.severe("[remove] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Server responed with an error: HTTP ${response.statusCode}", + ); + } + } + + @override + Future> getItems(Account account, NcAlbum album) async { + _log.info( + "[getItems] account: ${account.userId}, album: ${album.strippedPath}"); + final response = await ApiUtil.fromAccount(account).files().propfind( + path: album.path, + fileid: 1, + ); + if (!response.isGood) { + _log.severe("[getItems] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Server responed with an error: HTTP ${response.statusCode}", + ); + } + + final apiFiles = await api.FileParser().parse(response.body); + return apiFiles + .where((f) => f.fileId != null) + .map(ApiFileConverter.fromApi) + .map((f) => NcAlbumItem(f.fileId!)) + .toList(); + } +} + +@npLog +class NcAlbumSqliteDbDataSource implements NcAlbumCacheDataSource { + const NcAlbumSqliteDbDataSource(this.sqliteDb); + + @override + Future> getAlbums(Account account) async { + _log.info("[getAlbums] account: ${account.userId}"); + final dbAlbums = await sqliteDb.use((db) async { + return await db.ncAlbumsByAccount(account: sql.ByAccount.app(account)); + }); + return dbAlbums + .map((a) { + try { + return SqliteNcAlbumConverter.fromSql(account.userId.toString(), a); + } catch (e, stackTrace) { + _log.severe( + "[getAlbums] Failed while converting DB entry", e, stackTrace); + return null; + } + }) + .whereNotNull() + .toList(); + } + + @override + Future create(Account account, NcAlbum album) async { + _log.info("[create] account: ${account.userId}, album: ${album.path}"); + await sqliteDb.use((db) async { + await db.insertNcAlbum( + account: sql.ByAccount.app(account), + object: SqliteNcAlbumConverter.toSql(null, album), + ); + }); + } + + @override + Future remove(Account account, NcAlbum album) async { + _log.info("[remove] account: ${account.userId}, album: ${album.path}"); + await sqliteDb.use((db) async { + await db.deleteNcAlbumByRelativePath( + account: sql.ByAccount.app(account), + relativePath: album.strippedPath, + ); + }); + } + + @override + Future> getItems(Account account, NcAlbum album) async { + _log.info( + "[getItems] account: ${account.userId}, album: ${album.strippedPath}"); + final dbItems = await sqliteDb.use((db) async { + return await db.ncAlbumItemsByParentRelativePath( + account: sql.ByAccount.app(account), + parentRelativePath: album.strippedPath, + ); + }); + return dbItems.map((i) => NcAlbumItem(i.fileId)).toList(); + } + + @override + Future updateAlbumsCache(Account account, List remote) async { + await sqliteDb.use((db) async { + final dbAccount = await db.accountOf(account); + final existings = (await db.partialNcAlbumsByAccount( + account: sql.ByAccount.sql(dbAccount), + columns: [db.ncAlbums.rowId, db.ncAlbums.relativePath], + )) + .whereNotNull() + .toList(); + await db.batch((batch) async { + for (final r in remote) { + final dbObj = SqliteNcAlbumConverter.toSql(dbAccount, r); + final found = existings.indexWhere((e) => e[1] == r.strippedPath); + if (found != -1) { + // existing record, update it + batch.update( + db.ncAlbums, + dbObj, + where: (sql.$NcAlbumsTable t) => + t.rowId.equals(existings[found][0]), + ); + } else { + // insert + batch.insert(db.ncAlbums, dbObj); + } + } + for (final e in existings + .where((e) => !remote.any((r) => r.strippedPath == e[1]))) { + batch.deleteWhere( + db.ncAlbums, + (sql.$NcAlbumsTable t) => t.rowId.equals(e[0]), + ); + } + }); + }); + } + + @override + Future updateItemsCache( + Account account, NcAlbum album, List remote) async { + await sqliteDb.use((db) async { + final dbAlbum = await db.ncAlbumByRelativePath( + account: sql.ByAccount.app(account), + relativePath: album.strippedPath, + ); + final existingItems = await db.ncAlbumItemsByParent( + parent: dbAlbum!, + ); + final idDiff = list_util.diff( + existingItems.map((e) => e.fileId).sorted((a, b) => a.compareTo(b)), + remote.map((e) => e.fileId).sorted((a, b) => a.compareTo(b)), + ); + if (idDiff.onlyInA.isNotEmpty || idDiff.onlyInB.isNotEmpty) { + await db.batch((batch) async { + for (final id in idDiff.onlyInB) { + // new + batch.insert( + db.ncAlbumItems, + SqliteNcAlbumItemConverter.toSql(dbAlbum, id), + ); + } + // removed + batch.deleteWhere( + db.ncAlbumItems, + (sql.$NcAlbumItemsTable t) => + t.parent.equals(dbAlbum.rowId) & t.fileId.isIn(idDiff.onlyInA), + ); + }); + } + }); + } + + final sql.SqliteDb sqliteDb; +} diff --git a/app/lib/entity/nc_album/data_source.g.dart b/app/lib/entity/nc_album/data_source.g.dart new file mode 100644 index 00000000..873b74c7 --- /dev/null +++ b/app/lib/entity/nc_album/data_source.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_source.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$NcAlbumRemoteDataSourceNpLog on NcAlbumRemoteDataSource { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.nc_album.data_source.NcAlbumRemoteDataSource"); +} + +extension _$NcAlbumSqliteDbDataSourceNpLog on NcAlbumSqliteDbDataSource { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.nc_album.data_source.NcAlbumSqliteDbDataSource"); +} diff --git a/app/lib/entity/nc_album/item.dart b/app/lib/entity/nc_album/item.dart new file mode 100644 index 00000000..bb7bd76b --- /dev/null +++ b/app/lib/entity/nc_album/item.dart @@ -0,0 +1,5 @@ +class NcAlbumItem { + const NcAlbumItem(this.fileId); + + final int fileId; +} diff --git a/app/lib/entity/nc_album/repo.dart b/app/lib/entity/nc_album/repo.dart new file mode 100644 index 00000000..d27a124a --- /dev/null +++ b/app/lib/entity/nc_album/repo.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/entity/nc_album/item.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'repo.g.dart'; + +abstract class NcAlbumRepo { + /// Query all [NcAlbum]s belonging to [account] + /// + /// Normally the stream should complete with only a single event, but some + /// implementation might want to return multiple set of values, say one set of + /// cached value and later another set of updated value from a remote source. + /// In any case, each event is guaranteed to be one complete set of data + Stream> getAlbums(Account account); + + /// Create a new [album] + Future create(Account account, NcAlbum album); + + /// Remove [album] + Future remove(Account account, NcAlbum album); + + /// Query all items belonging to [album] + Stream> getItems(Account account, NcAlbum album); +} + +/// A repo that simply relay the call to the backed [NcAlbumDataSource] +@npLog +class BasicNcAlbumRepo implements NcAlbumRepo { + const BasicNcAlbumRepo(this.dataSrc); + + @override + Stream> getAlbums(Account account) async* { + yield await dataSrc.getAlbums(account); + } + + @override + Future create(Account account, NcAlbum album) => + dataSrc.create(account, album); + + @override + Future remove(Account account, NcAlbum album) => + dataSrc.remove(account, album); + + @override + Stream> getItems(Account account, NcAlbum album) async* { + yield await dataSrc.getItems(account, album); + } + + final NcAlbumDataSource dataSrc; +} + +/// A repo that manage a remote data source and a cache data source +@npLog +class CachedNcAlbumRepo implements NcAlbumRepo { + const CachedNcAlbumRepo(this.remoteDataSrc, this.cacheDataSrc); + + @override + Stream> getAlbums(Account account) async* { + // get cache + try { + yield await cacheDataSrc.getAlbums(account); + } catch (e, stackTrace) { + _log.shout("[getAlbums] Cache failure", e, stackTrace); + } + + // query remote + final remote = await remoteDataSrc.getAlbums(account); + yield remote; + + // update cache + unawaited(cacheDataSrc.updateAlbumsCache(account, remote)); + } + + @override + Future create(Account account, NcAlbum album) async { + await remoteDataSrc.create(account, album); + try { + await cacheDataSrc.create(account, album); + } catch (e, stackTrace) { + _log.warning("[create] Failed to insert cache", e, stackTrace); + } + } + + @override + Future remove(Account account, NcAlbum album) async { + await remoteDataSrc.remove(account, album); + try { + await cacheDataSrc.remove(account, album); + } catch (e, stackTrace) { + _log.warning("[remove] Failed to remove cache", e, stackTrace); + } + } + + @override + Stream> getItems(Account account, NcAlbum album) async* { + // get cache + try { + yield await cacheDataSrc.getItems(account, album); + } catch (e, stackTrace) { + _log.shout("[getItems] Cache failure", e, stackTrace); + } + + // query remote + final remote = await remoteDataSrc.getItems(account, album); + yield remote; + + // update cache + await cacheDataSrc.updateItemsCache(account, album, remote); + } + + final NcAlbumDataSource remoteDataSrc; + final NcAlbumCacheDataSource cacheDataSrc; +} + +abstract class NcAlbumDataSource { + /// Query all [NcAlbum]s belonging to [account] + Future> getAlbums(Account account); + + /// Create a new [album] + Future create(Account account, NcAlbum album); + + /// Remove [album] + Future remove(Account account, NcAlbum album); + + /// Query all items belonging to [album] + Future> getItems(Account account, NcAlbum album); +} + +abstract class NcAlbumCacheDataSource extends NcAlbumDataSource { + /// Update cache to match [remote] + Future updateAlbumsCache(Account account, List remote); + + /// Update cache to match [remote] + Future updateItemsCache( + Account account, NcAlbum album, List remote); +} diff --git a/app/lib/entity/nc_album/repo.g.dart b/app/lib/entity/nc_album/repo.g.dart new file mode 100644 index 00000000..e1558ab0 --- /dev/null +++ b/app/lib/entity/nc_album/repo.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'repo.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$BasicNcAlbumRepoNpLog on BasicNcAlbumRepo { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.nc_album.repo.BasicNcAlbumRepo"); +} + +extension _$CachedNcAlbumRepoNpLog on CachedNcAlbumRepo { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.nc_album.repo.CachedNcAlbumRepo"); +} diff --git a/app/lib/entity/sqlite/database.dart b/app/lib/entity/sqlite/database.dart index 7de7502d..4bd8ad6a 100644 --- a/app/lib/entity/sqlite/database.dart +++ b/app/lib/entity/sqlite/database.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart' as app; import 'package:nc_photos/entity/file.dart' as app; -import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/file_descriptor.dart' as app; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/sqlite/files_query_builder.dart'; import 'package:nc_photos/entity/sqlite/isolate_util.dart'; @@ -19,6 +19,7 @@ import 'package:np_codegen/np_codegen.dart'; part 'database.g.dart'; part 'database_extension.dart'; +part 'database/nc_album_extension.dart'; // remember to also update the truncate method after adding a new table @npLog @@ -36,6 +37,8 @@ part 'database_extension.dart'; AlbumShares, Tags, Persons, + NcAlbums, + NcAlbumItems, ], ) class SqliteDb extends _$SqliteDb { diff --git a/app/lib/entity/sqlite/database.g.dart b/app/lib/entity/sqlite/database.g.dart index 473f536f..a1de07c5 100644 --- a/app/lib/entity/sqlite/database.g.dart +++ b/app/lib/entity/sqlite/database.g.dart @@ -4157,6 +4157,639 @@ class $PersonsTable extends Persons with TableInfo<$PersonsTable, Person> { } } +class NcAlbum extends DataClass implements Insertable { + final int rowId; + final int account; + final String relativePath; + final int? lastPhoto; + final int nbItems; + final String? location; + final DateTime? dateStart; + final DateTime? dateEnd; + NcAlbum( + {required this.rowId, + required this.account, + required this.relativePath, + this.lastPhoto, + required this.nbItems, + this.location, + this.dateStart, + this.dateEnd}); + factory NcAlbum.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return NcAlbum( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + account: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}account'])!, + relativePath: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}relative_path'])!, + lastPhoto: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}last_photo']), + nbItems: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}nb_items'])!, + location: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}location']), + dateStart: $NcAlbumsTable.$converter0.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}date_start'])), + dateEnd: $NcAlbumsTable.$converter1.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}date_end'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['account'] = Variable(account); + map['relative_path'] = Variable(relativePath); + if (!nullToAbsent || lastPhoto != null) { + map['last_photo'] = Variable(lastPhoto); + } + map['nb_items'] = Variable(nbItems); + if (!nullToAbsent || location != null) { + map['location'] = Variable(location); + } + if (!nullToAbsent || dateStart != null) { + final converter = $NcAlbumsTable.$converter0; + map['date_start'] = Variable(converter.mapToSql(dateStart)); + } + if (!nullToAbsent || dateEnd != null) { + final converter = $NcAlbumsTable.$converter1; + map['date_end'] = Variable(converter.mapToSql(dateEnd)); + } + return map; + } + + NcAlbumsCompanion toCompanion(bool nullToAbsent) { + return NcAlbumsCompanion( + rowId: Value(rowId), + account: Value(account), + relativePath: Value(relativePath), + lastPhoto: lastPhoto == null && nullToAbsent + ? const Value.absent() + : Value(lastPhoto), + nbItems: Value(nbItems), + location: location == null && nullToAbsent + ? const Value.absent() + : Value(location), + dateStart: dateStart == null && nullToAbsent + ? const Value.absent() + : Value(dateStart), + dateEnd: dateEnd == null && nullToAbsent + ? const Value.absent() + : Value(dateEnd), + ); + } + + factory NcAlbum.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return NcAlbum( + rowId: serializer.fromJson(json['rowId']), + account: serializer.fromJson(json['account']), + relativePath: serializer.fromJson(json['relativePath']), + lastPhoto: serializer.fromJson(json['lastPhoto']), + nbItems: serializer.fromJson(json['nbItems']), + location: serializer.fromJson(json['location']), + dateStart: serializer.fromJson(json['dateStart']), + dateEnd: serializer.fromJson(json['dateEnd']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'account': serializer.toJson(account), + 'relativePath': serializer.toJson(relativePath), + 'lastPhoto': serializer.toJson(lastPhoto), + 'nbItems': serializer.toJson(nbItems), + 'location': serializer.toJson(location), + 'dateStart': serializer.toJson(dateStart), + 'dateEnd': serializer.toJson(dateEnd), + }; + } + + NcAlbum copyWith( + {int? rowId, + int? account, + String? relativePath, + Value lastPhoto = const Value.absent(), + int? nbItems, + Value location = const Value.absent(), + Value dateStart = const Value.absent(), + Value dateEnd = const Value.absent()}) => + NcAlbum( + rowId: rowId ?? this.rowId, + account: account ?? this.account, + relativePath: relativePath ?? this.relativePath, + lastPhoto: lastPhoto.present ? lastPhoto.value : this.lastPhoto, + nbItems: nbItems ?? this.nbItems, + location: location.present ? location.value : this.location, + dateStart: dateStart.present ? dateStart.value : this.dateStart, + dateEnd: dateEnd.present ? dateEnd.value : this.dateEnd, + ); + @override + String toString() { + return (StringBuffer('NcAlbum(') + ..write('rowId: $rowId, ') + ..write('account: $account, ') + ..write('relativePath: $relativePath, ') + ..write('lastPhoto: $lastPhoto, ') + ..write('nbItems: $nbItems, ') + ..write('location: $location, ') + ..write('dateStart: $dateStart, ') + ..write('dateEnd: $dateEnd') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(rowId, account, relativePath, lastPhoto, + nbItems, location, dateStart, dateEnd); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NcAlbum && + other.rowId == this.rowId && + other.account == this.account && + other.relativePath == this.relativePath && + other.lastPhoto == this.lastPhoto && + other.nbItems == this.nbItems && + other.location == this.location && + other.dateStart == this.dateStart && + other.dateEnd == this.dateEnd); +} + +class NcAlbumsCompanion extends UpdateCompanion { + final Value rowId; + final Value account; + final Value relativePath; + final Value lastPhoto; + final Value nbItems; + final Value location; + final Value dateStart; + final Value dateEnd; + const NcAlbumsCompanion({ + this.rowId = const Value.absent(), + this.account = const Value.absent(), + this.relativePath = const Value.absent(), + this.lastPhoto = const Value.absent(), + this.nbItems = const Value.absent(), + this.location = const Value.absent(), + this.dateStart = const Value.absent(), + this.dateEnd = const Value.absent(), + }); + NcAlbumsCompanion.insert({ + this.rowId = const Value.absent(), + required int account, + required String relativePath, + this.lastPhoto = const Value.absent(), + required int nbItems, + this.location = const Value.absent(), + this.dateStart = const Value.absent(), + this.dateEnd = const Value.absent(), + }) : account = Value(account), + relativePath = Value(relativePath), + nbItems = Value(nbItems); + static Insertable custom({ + Expression? rowId, + Expression? account, + Expression? relativePath, + Expression? lastPhoto, + Expression? nbItems, + Expression? location, + Expression? dateStart, + Expression? dateEnd, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (account != null) 'account': account, + if (relativePath != null) 'relative_path': relativePath, + if (lastPhoto != null) 'last_photo': lastPhoto, + if (nbItems != null) 'nb_items': nbItems, + if (location != null) 'location': location, + if (dateStart != null) 'date_start': dateStart, + if (dateEnd != null) 'date_end': dateEnd, + }); + } + + NcAlbumsCompanion copyWith( + {Value? rowId, + Value? account, + Value? relativePath, + Value? lastPhoto, + Value? nbItems, + Value? location, + Value? dateStart, + Value? dateEnd}) { + return NcAlbumsCompanion( + rowId: rowId ?? this.rowId, + account: account ?? this.account, + relativePath: relativePath ?? this.relativePath, + lastPhoto: lastPhoto ?? this.lastPhoto, + nbItems: nbItems ?? this.nbItems, + location: location ?? this.location, + dateStart: dateStart ?? this.dateStart, + dateEnd: dateEnd ?? this.dateEnd, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (account.present) { + map['account'] = Variable(account.value); + } + if (relativePath.present) { + map['relative_path'] = Variable(relativePath.value); + } + if (lastPhoto.present) { + map['last_photo'] = Variable(lastPhoto.value); + } + if (nbItems.present) { + map['nb_items'] = Variable(nbItems.value); + } + if (location.present) { + map['location'] = Variable(location.value); + } + if (dateStart.present) { + final converter = $NcAlbumsTable.$converter0; + map['date_start'] = + Variable(converter.mapToSql(dateStart.value)); + } + if (dateEnd.present) { + final converter = $NcAlbumsTable.$converter1; + map['date_end'] = Variable(converter.mapToSql(dateEnd.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('NcAlbumsCompanion(') + ..write('rowId: $rowId, ') + ..write('account: $account, ') + ..write('relativePath: $relativePath, ') + ..write('lastPhoto: $lastPhoto, ') + ..write('nbItems: $nbItems, ') + ..write('location: $location, ') + ..write('dateStart: $dateStart, ') + ..write('dateEnd: $dateEnd') + ..write(')')) + .toString(); + } +} + +class $NcAlbumsTable extends NcAlbums with TableInfo<$NcAlbumsTable, NcAlbum> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $NcAlbumsTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _accountMeta = const VerificationMeta('account'); + @override + late final GeneratedColumn account = GeneratedColumn( + 'account', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES accounts (row_id) ON DELETE CASCADE'); + final VerificationMeta _relativePathMeta = + const VerificationMeta('relativePath'); + @override + late final GeneratedColumn relativePath = GeneratedColumn( + 'relative_path', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _lastPhotoMeta = const VerificationMeta('lastPhoto'); + @override + late final GeneratedColumn lastPhoto = GeneratedColumn( + 'last_photo', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _nbItemsMeta = const VerificationMeta('nbItems'); + @override + late final GeneratedColumn nbItems = GeneratedColumn( + 'nb_items', aliasedName, false, + type: const IntType(), requiredDuringInsert: true); + final VerificationMeta _locationMeta = const VerificationMeta('location'); + @override + late final GeneratedColumn location = GeneratedColumn( + 'location', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _dateStartMeta = const VerificationMeta('dateStart'); + @override + late final GeneratedColumnWithTypeConverter dateStart = + GeneratedColumn('date_start', aliasedName, true, + type: const IntType(), requiredDuringInsert: false) + .withConverter($NcAlbumsTable.$converter0); + final VerificationMeta _dateEndMeta = const VerificationMeta('dateEnd'); + @override + late final GeneratedColumnWithTypeConverter dateEnd = + GeneratedColumn('date_end', aliasedName, true, + type: const IntType(), requiredDuringInsert: false) + .withConverter($NcAlbumsTable.$converter1); + @override + List get $columns => [ + rowId, + account, + relativePath, + lastPhoto, + nbItems, + location, + dateStart, + dateEnd + ]; + @override + String get aliasedName => _alias ?? 'nc_albums'; + @override + String get actualTableName => 'nc_albums'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('account')) { + context.handle(_accountMeta, + account.isAcceptableOrUnknown(data['account']!, _accountMeta)); + } else if (isInserting) { + context.missing(_accountMeta); + } + if (data.containsKey('relative_path')) { + context.handle( + _relativePathMeta, + relativePath.isAcceptableOrUnknown( + data['relative_path']!, _relativePathMeta)); + } else if (isInserting) { + context.missing(_relativePathMeta); + } + if (data.containsKey('last_photo')) { + context.handle(_lastPhotoMeta, + lastPhoto.isAcceptableOrUnknown(data['last_photo']!, _lastPhotoMeta)); + } + if (data.containsKey('nb_items')) { + context.handle(_nbItemsMeta, + nbItems.isAcceptableOrUnknown(data['nb_items']!, _nbItemsMeta)); + } else if (isInserting) { + context.missing(_nbItemsMeta); + } + if (data.containsKey('location')) { + context.handle(_locationMeta, + location.isAcceptableOrUnknown(data['location']!, _locationMeta)); + } + context.handle(_dateStartMeta, const VerificationResult.success()); + context.handle(_dateEndMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {account, relativePath}, + ]; + @override + NcAlbum map(Map data, {String? tablePrefix}) { + return NcAlbum.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $NcAlbumsTable createAlias(String alias) { + return $NcAlbumsTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const SqliteDateTimeConverter(); + static TypeConverter $converter1 = + const SqliteDateTimeConverter(); +} + +class NcAlbumItem extends DataClass implements Insertable { + final int rowId; + final int parent; + final int fileId; + NcAlbumItem( + {required this.rowId, required this.parent, required this.fileId}); + factory NcAlbumItem.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return NcAlbumItem( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + parent: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}parent'])!, + fileId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}file_id'])!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['parent'] = Variable(parent); + map['file_id'] = Variable(fileId); + return map; + } + + NcAlbumItemsCompanion toCompanion(bool nullToAbsent) { + return NcAlbumItemsCompanion( + rowId: Value(rowId), + parent: Value(parent), + fileId: Value(fileId), + ); + } + + factory NcAlbumItem.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return NcAlbumItem( + rowId: serializer.fromJson(json['rowId']), + parent: serializer.fromJson(json['parent']), + fileId: serializer.fromJson(json['fileId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'parent': serializer.toJson(parent), + 'fileId': serializer.toJson(fileId), + }; + } + + NcAlbumItem copyWith({int? rowId, int? parent, int? fileId}) => NcAlbumItem( + rowId: rowId ?? this.rowId, + parent: parent ?? this.parent, + fileId: fileId ?? this.fileId, + ); + @override + String toString() { + return (StringBuffer('NcAlbumItem(') + ..write('rowId: $rowId, ') + ..write('parent: $parent, ') + ..write('fileId: $fileId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(rowId, parent, fileId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NcAlbumItem && + other.rowId == this.rowId && + other.parent == this.parent && + other.fileId == this.fileId); +} + +class NcAlbumItemsCompanion extends UpdateCompanion { + final Value rowId; + final Value parent; + final Value fileId; + const NcAlbumItemsCompanion({ + this.rowId = const Value.absent(), + this.parent = const Value.absent(), + this.fileId = const Value.absent(), + }); + NcAlbumItemsCompanion.insert({ + this.rowId = const Value.absent(), + required int parent, + required int fileId, + }) : parent = Value(parent), + fileId = Value(fileId); + static Insertable custom({ + Expression? rowId, + Expression? parent, + Expression? fileId, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (parent != null) 'parent': parent, + if (fileId != null) 'file_id': fileId, + }); + } + + NcAlbumItemsCompanion copyWith( + {Value? rowId, Value? parent, Value? fileId}) { + return NcAlbumItemsCompanion( + rowId: rowId ?? this.rowId, + parent: parent ?? this.parent, + fileId: fileId ?? this.fileId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (parent.present) { + map['parent'] = Variable(parent.value); + } + if (fileId.present) { + map['file_id'] = Variable(fileId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('NcAlbumItemsCompanion(') + ..write('rowId: $rowId, ') + ..write('parent: $parent, ') + ..write('fileId: $fileId') + ..write(')')) + .toString(); + } +} + +class $NcAlbumItemsTable extends NcAlbumItems + with TableInfo<$NcAlbumItemsTable, NcAlbumItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $NcAlbumItemsTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _parentMeta = const VerificationMeta('parent'); + @override + late final GeneratedColumn parent = GeneratedColumn( + 'parent', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES nc_albums (row_id) ON DELETE CASCADE'); + final VerificationMeta _fileIdMeta = const VerificationMeta('fileId'); + @override + late final GeneratedColumn fileId = GeneratedColumn( + 'file_id', aliasedName, false, + type: const IntType(), requiredDuringInsert: true); + @override + List get $columns => [rowId, parent, fileId]; + @override + String get aliasedName => _alias ?? 'nc_album_items'; + @override + String get actualTableName => 'nc_album_items'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('parent')) { + context.handle(_parentMeta, + parent.isAcceptableOrUnknown(data['parent']!, _parentMeta)); + } else if (isInserting) { + context.missing(_parentMeta); + } + if (data.containsKey('file_id')) { + context.handle(_fileIdMeta, + fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta)); + } else if (isInserting) { + context.missing(_fileIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {parent, fileId}, + ]; + @override + NcAlbumItem map(Map data, {String? tablePrefix}) { + return NcAlbumItem.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $NcAlbumItemsTable createAlias(String alias) { + return $NcAlbumItemsTable(attachedDatabase, alias); + } +} + abstract class _$SqliteDb extends GeneratedDatabase { _$SqliteDb(QueryExecutor e) : super(SqlTypeSystem.defaultInstance, e); _$SqliteDb.connect(DatabaseConnection c) : super.connect(c); @@ -4172,6 +4805,8 @@ abstract class _$SqliteDb extends GeneratedDatabase { late final $AlbumSharesTable albumShares = $AlbumSharesTable(this); late final $TagsTable tags = $TagsTable(this); late final $PersonsTable persons = $PersonsTable(this); + late final $NcAlbumsTable ncAlbums = $NcAlbumsTable(this); + late final $NcAlbumItemsTable ncAlbumItems = $NcAlbumItemsTable(this); @override Iterable get allTables => allSchemaEntities.whereType(); @override @@ -4187,7 +4822,9 @@ abstract class _$SqliteDb extends GeneratedDatabase { albums, albumShares, tags, - persons + persons, + ncAlbums, + ncAlbumItems ]; } diff --git a/app/lib/entity/sqlite/database/nc_album_extension.dart b/app/lib/entity/sqlite/database/nc_album_extension.dart new file mode 100644 index 00000000..ae0e4ef2 --- /dev/null +++ b/app/lib/entity/sqlite/database/nc_album_extension.dart @@ -0,0 +1,137 @@ +part of '../database.dart'; + +extension SqliteDbNcAlbumExtension on SqliteDb { + Future> ncAlbumsByAccount({ + required ByAccount account, + }) { + assert((account.sqlAccount != null) != (account.appAccount != null)); + if (account.sqlAccount != null) { + final query = select(ncAlbums) + ..where((t) => t.account.equals(account.sqlAccount!.rowId)); + return query.get(); + } else { + final query = select(ncAlbums).join([ + innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account), + useColumns: false), + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false), + ]) + ..where(servers.address.equals(account.appAccount!.url)) + ..where(accounts.userId + .equals(account.appAccount!.userId.toCaseInsensitiveString())); + return query.map((r) => r.readTable(ncAlbums)).get(); + } + } + + Future> partialNcAlbumsByAccount({ + required ByAccount account, + required List columns, + }) { + final query = selectOnly(ncAlbums)..addColumns(columns); + if (account.sqlAccount != null) { + query.where(ncAlbums.account.equals(account.sqlAccount!.rowId)); + } else { + query.join([ + innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account), + useColumns: false), + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false), + ]) + ..where(servers.address.equals(account.appAccount!.url)) + ..where(accounts.userId + .equals(account.appAccount!.userId.toCaseInsensitiveString())); + } + return query.map((r) => columns.map((c) => r.read(c)).toList()).get(); + } + + Future ncAlbumByRelativePath({ + required ByAccount account, + required String relativePath, + }) { + if (account.sqlAccount != null) { + final query = select(ncAlbums) + ..where((t) => t.account.equals(account.sqlAccount!.rowId)) + ..where((t) => t.relativePath.equals(relativePath)); + return query.getSingleOrNull(); + } else { + final query = select(ncAlbums).join([ + innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account), + useColumns: false), + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false), + ]) + ..where(servers.address.equals(account.appAccount!.url)) + ..where(accounts.userId + .equals(account.appAccount!.userId.toCaseInsensitiveString())) + ..where(ncAlbums.relativePath.equals(relativePath)); + return query.map((r) => r.readTable(ncAlbums)).getSingleOrNull(); + } + } + + Future insertNcAlbum({ + required ByAccount account, + required NcAlbumsCompanion object, + }) async { + final Account dbAccount; + if (account.sqlAccount != null) { + dbAccount = account.sqlAccount!; + } else { + dbAccount = await accountOf(account.appAccount!); + } + await into(ncAlbums).insert(object.copyWith( + account: Value(dbAccount.rowId), + )); + } + + /// Delete [NaAlbum] by relativePath + /// + /// Return the number of deleted rows + Future deleteNcAlbumByRelativePath({ + required ByAccount account, + required String relativePath, + }) async { + final Account dbAccount; + if (account.sqlAccount != null) { + dbAccount = account.sqlAccount!; + } else { + dbAccount = await accountOf(account.appAccount!); + } + return await (delete(ncAlbums) + ..where((t) => t.account.equals(dbAccount.rowId)) + ..where((t) => t.relativePath.equals(relativePath))) + .go(); + } + + Future> ncAlbumItemsByParent({ + required NcAlbum parent, + }) { + final query = select(ncAlbumItems) + ..where((t) => t.parent.equals(parent.rowId)); + return query.get(); + } + + Future> ncAlbumItemsByParentRelativePath({ + required ByAccount account, + required String parentRelativePath, + }) { + final query = select(ncAlbumItems).join([ + innerJoin(ncAlbums, ncAlbums.rowId.equalsExp(ncAlbumItems.parent), + useColumns: false), + ]); + if (account.sqlAccount != null) { + query.where(ncAlbums.account.equals(account.sqlAccount!.rowId)); + } else { + query.join([ + innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account), + useColumns: false), + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false), + ]) + ..where(servers.address.equals(account.appAccount!.url)) + ..where(accounts.userId + .equals(account.appAccount!.userId.toCaseInsensitiveString())); + } + query.where(ncAlbums.relativePath.equals(parentRelativePath)); + return query.map((r) => r.readTable(ncAlbumItems)).get(); + } +} diff --git a/app/lib/entity/sqlite/database_extension.dart b/app/lib/entity/sqlite/database_extension.dart index f252e11b..26c0b34b 100644 --- a/app/lib/entity/sqlite/database_extension.dart +++ b/app/lib/entity/sqlite/database_extension.dart @@ -42,6 +42,32 @@ extension FileListExtension on List { } } +class FileDescriptor { + const FileDescriptor({ + required this.relativePath, + required this.fileId, + required this.contentType, + required this.isArchived, + required this.isFavorite, + required this.bestDateTime, + }); + + final String relativePath; + final int fileId; + final String? contentType; + final bool? isArchived; + final bool? isFavorite; + final DateTime bestDateTime; +} + +extension FileDescriptorListExtension on List { + List convertToAppFileDescriptor(app.Account account) { + return map((f) => + SqliteFileDescriptorConverter.fromSql(account.userId.toString(), f)) + .toList(); + } +} + class AlbumWithShare { const AlbumWithShare(this.album, this.share); @@ -75,6 +101,20 @@ class AccountFileRowIdsWithFileId { final int fileId; } +class ByAccount { + const ByAccount.sql(Account account) : this._(sqlAccount: account); + + const ByAccount.app(app.Account account) : this._(appAccount: account); + + const ByAccount._({ + this.sqlAccount, + this.appAccount, + }) : assert((sqlAccount != null) != (appAccount != null)); + + final Account? sqlAccount; + final app.Account? appAccount; +} + extension SqliteDbExtension on SqliteDb { /// Start a transaction and run [block] /// @@ -234,7 +274,7 @@ extension SqliteDbExtension on SqliteDb { /// /// Only one of [sqlAccount] and [appAccount] must be passed Future accountFileRowIdsOfOrNull( - app.File file, { + app.FileDescriptor file, { Account? sqlAccount, app.Account? appAccount, }) { @@ -250,9 +290,9 @@ extension SqliteDbExtension on SqliteDb { } else { q.setAppAccount(appAccount!); } - if (file.fileId != null) { - q.byFileId(file.fileId!); - } else { + try { + q.byFileId(file.fdId); + } catch (_) { q.byRelativePath(file.strippedPathWithEmpty); } return q.build()..limit(1); @@ -268,7 +308,7 @@ extension SqliteDbExtension on SqliteDb { /// See [accountFileRowIdsOfOrNull] Future accountFileRowIdsOf( - app.File file, { + app.FileDescriptor file, { Account? sqlAccount, app.Account? appAccount, }) => @@ -398,6 +438,45 @@ extension SqliteDbExtension on SqliteDb { .get(); } + /// Query [FileDescriptor]s by fileId + /// + /// Returned files are NOT guaranteed to be sorted as [fileIds] + Future> fileDescriptorsByFileIds( + ByAccount account, Iterable fileIds) { + return fileIds.withPartition((sublist) { + final query = queryFiles().run((q) { + q.setQueryMode( + FilesQueryMode.expression, + expressions: [ + accountFiles.relativePath, + files.fileId, + files.contentType, + accountFiles.isArchived, + accountFiles.isFavorite, + accountFiles.bestDateTime, + ], + ); + if (account.sqlAccount != null) { + q.setSqlAccount(account.sqlAccount!); + } else { + q.setAppAccount(account.appAccount!); + } + q.byFileIds(sublist); + return q.build(); + }); + return query + .map((r) => FileDescriptor( + relativePath: r.read(accountFiles.relativePath)!, + fileId: r.read(files.fileId)!, + contentType: r.read(files.contentType), + isArchived: r.read(accountFiles.isArchived), + isFavorite: r.read(accountFiles.isFavorite), + bestDateTime: r.read(accountFiles.bestDateTime)!, + )) + .get(); + }, maxByFileIdsSize); + } + Future> allTags({ Account? sqlAccount, app.Account? appAccount, @@ -553,6 +632,8 @@ extension SqliteDbExtension on SqliteDb { await delete(albumShares).go(); await delete(tags).go(); await delete(persons).go(); + await delete(ncAlbums).go(); + await delete(ncAlbumItems).go(); // reset the auto increment counter await customStatement("UPDATE sqlite_sequence SET seq=0;"); diff --git a/app/lib/entity/sqlite/table.dart b/app/lib/entity/sqlite/table.dart index 4abd51c0..965fb018 100644 --- a/app/lib/entity/sqlite/table.dart +++ b/app/lib/entity/sqlite/table.dart @@ -125,6 +125,37 @@ class DirFiles extends Table { get primaryKey => {dir, child}; } +class NcAlbums extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get account => + integer().references(Accounts, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get relativePath => text()(); + IntColumn get lastPhoto => integer().nullable()(); + IntColumn get nbItems => integer()(); + TextColumn get location => text().nullable()(); + DateTimeColumn get dateStart => + dateTime().map(const SqliteDateTimeConverter()).nullable()(); + DateTimeColumn get dateEnd => + dateTime().map(const SqliteDateTimeConverter()).nullable()(); + + @override + List>? get uniqueKeys => [ + {account, relativePath}, + ]; +} + +class NcAlbumItems extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get parent => + integer().references(NcAlbums, #rowId, onDelete: KeyAction.cascade)(); + IntColumn get fileId => integer()(); + + @override + List>? get uniqueKeys => [ + {parent, fileId}, + ]; +} + class Albums extends Table { IntColumn get rowId => integer().autoIncrement()(); IntColumn get file => integer() diff --git a/app/lib/entity/sqlite/type_converter.dart b/app/lib/entity/sqlite/type_converter.dart index 3f42552d..c772238d 100644 --- a/app/lib/entity/sqlite/type_converter.dart +++ b/app/lib/entity/sqlite/type_converter.dart @@ -8,6 +8,7 @@ import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/entity/tag.dart'; @@ -110,6 +111,19 @@ class SqliteAlbumConverter { } } +class SqliteFileDescriptorConverter { + static FileDescriptor fromSql(String userId, sql.FileDescriptor f) { + return FileDescriptor( + fdPath: "remote.php/dav/files/$userId/${f.relativePath}", + fdId: f.fileId, + fdMime: f.contentType, + fdIsArchived: f.isArchived ?? false, + fdIsFavorite: f.isFavorite ?? false, + fdDateTime: f.bestDateTime, + ); + } +} + class SqliteFileConverter { static File fromSql(String userId, sql.CompleteFile f) { final metadata = f.image?.run((obj) => Metadata( @@ -239,6 +253,43 @@ class SqlitePersonConverter { ); } +class SqliteNcAlbumConverter { + static NcAlbum fromSql(String userId, sql.NcAlbum ncAlbum) => NcAlbum( + path: "remote.php/dav/photos/$userId/albums/${ncAlbum.relativePath}", + lastPhoto: ncAlbum.lastPhoto, + nbItems: ncAlbum.nbItems, + location: ncAlbum.location, + dateStart: ncAlbum.dateStart, + dateEnd: ncAlbum.dateEnd, + collaborators: [], + ); + + static sql.NcAlbumsCompanion toSql(sql.Account? dbAccount, NcAlbum ncAlbum) => + sql.NcAlbumsCompanion( + account: + dbAccount == null ? const Value.absent() : Value(dbAccount.rowId), + relativePath: Value(ncAlbum.strippedPath), + lastPhoto: Value(ncAlbum.lastPhoto), + nbItems: Value(ncAlbum.nbItems), + location: Value(ncAlbum.location), + dateStart: Value(ncAlbum.dateStart), + dateEnd: Value(ncAlbum.dateEnd), + ); +} + +class SqliteNcAlbumItemConverter { + static int fromSql(sql.NcAlbumItem item) => item.fileId; + + static sql.NcAlbumItemsCompanion toSql( + sql.NcAlbum parent, + int fileId, + ) => + sql.NcAlbumItemsCompanion( + parent: Value(parent.rowId), + fileId: Value(fileId), + ); +} + sql.TagsCompanion _convertAppTag(Map map) { final account = map["account"] as sql.Account?; final tag = map["tag"] as Tag; diff --git a/app/lib/event/event.dart b/app/lib/event/event.dart index 11eacfc0..3e281cb5 100644 --- a/app/lib/event/event.dart +++ b/app/lib/event/event.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/pref.dart'; @@ -78,7 +79,7 @@ class FileRemovedEvent { FileRemovedEvent(this.account, this.file); final Account account; - final File file; + final FileDescriptor file; } class FileTrashbinRestoredEvent { diff --git a/app/lib/exception.dart b/app/lib/exception.dart index 9a26bac7..714edf01 100644 --- a/app/lib/exception.dart +++ b/app/lib/exception.dart @@ -1,7 +1,7 @@ import 'package:np_api/np_api.dart'; class CacheNotFoundException implements Exception { - CacheNotFoundException([this.message]); + const CacheNotFoundException([this.message]); @override toString() { @@ -94,3 +94,12 @@ class InterruptedException implements Exception { final dynamic message; } + +class AlbumItemPermissionException implements Exception { + const AlbumItemPermissionException([this.message]); + + @override + toString() => "AlbumItemPermissionException: $message"; + + final dynamic message; +} diff --git a/app/lib/flutter_util.dart b/app/lib/flutter_util.dart index 367c9b04..6fdf6e72 100644 --- a/app/lib/flutter_util.dart +++ b/app/lib/flutter_util.dart @@ -20,6 +20,8 @@ class CustomizableMaterialPageRoute extends MaterialPageRoute { String getImageHeroTag(FileDescriptor file) => "imageHero(${file.fdPath})"; +String getCollectionHeroTag(String coverUrl) => "collectionHero($coverUrl)"; + // copied from flutter Widget defaultHeroFlightShuttleBuilder( BuildContext flightContext, diff --git a/app/lib/lazy.dart b/app/lib/lazy.dart new file mode 100644 index 00000000..954d8ca4 --- /dev/null +++ b/app/lib/lazy.dart @@ -0,0 +1,16 @@ +class Lazy { + Lazy(this.build); + + T call() { + if (build != null) { + _value = build!(); + build = null; + } + return _value; + } + + T get get => call(); + + T Function()? build; + late final T _value; +} diff --git a/app/lib/list_extension.dart b/app/lib/list_extension.dart index 4be74efa..a2452b02 100644 --- a/app/lib/list_extension.dart +++ b/app/lib/list_extension.dart @@ -49,4 +49,8 @@ extension ListExtension on List { } return this; } + + Future> asyncMap(Future Function(T element) fn) { + return Stream.fromIterable(this).asyncMap(fn).toList(); + } } diff --git a/app/lib/main.dart b/app/lib/main.dart index 612cfe63..c4b3abe1 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/app_init.dart' as app_init; +import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/widget/my_app.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -33,8 +34,9 @@ void main() async { @npLog class _BlocObserver extends BlocObserver { @override - onChange(BlocBase bloc, Change change) { + void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); - _log.finer("${bloc.runtimeType} $change"); + final tag = bloc is BlocTag ? (bloc as BlocTag).tag : bloc.runtimeType; + _log.finer("$tag $change"); } } diff --git a/app/lib/material3.dart b/app/lib/material3.dart index be68aff7..48bc123f 100644 --- a/app/lib/material3.dart +++ b/app/lib/material3.dart @@ -4,7 +4,6 @@ class M3 extends ThemeExtension { const M3({ required this.seed, required this.checkbox, - required this.assistChip, required this.filterChip, required this.listTile, }); @@ -15,14 +14,12 @@ class M3 extends ThemeExtension { M3 copyWith({ Color? seed, M3Checkbox? checkbox, - M3AssistChip? assistChip, M3FilterChip? filterChip, M3ListTile? listTile, }) => M3( seed: seed ?? this.seed, checkbox: checkbox ?? this.checkbox, - assistChip: assistChip ?? this.assistChip, filterChip: filterChip ?? this.filterChip, listTile: listTile ?? this.listTile, ); @@ -35,7 +32,6 @@ class M3 extends ThemeExtension { return M3( seed: Color.lerp(seed, other.seed, t)!, checkbox: checkbox.lerp(other.checkbox, t), - assistChip: assistChip.lerp(other.assistChip, t), filterChip: filterChip.lerp(other.filterChip, t), listTile: listTile.lerp(other.listTile, t), ); @@ -43,7 +39,6 @@ class M3 extends ThemeExtension { final Color seed; final M3Checkbox checkbox; - final M3AssistChip assistChip; final M3FilterChip filterChip; final M3ListTile listTile; } @@ -82,44 +77,6 @@ class M3CheckboxDisabled { final Color container; } -class M3AssistChip { - const M3AssistChip({ - required this.enabled, - }); - - M3AssistChip lerp(M3AssistChip? other, double t) { - if (other is! M3AssistChip) { - return this; - } - return M3AssistChip( - enabled: enabled.lerp(other.enabled, t), - ); - } - - final M3AssistChipEnabled enabled; -} - -class M3AssistChipEnabled { - const M3AssistChipEnabled({ - required this.container, - required this.containerElevated, - }); - - M3AssistChipEnabled lerp(M3AssistChipEnabled? other, double t) { - if (other is! M3AssistChipEnabled) { - return this; - } - return M3AssistChipEnabled( - container: Color.lerp(container, other.container, t)!, - containerElevated: - Color.lerp(containerElevated, other.containerElevated, t)!, - ); - } - - final Color container; - final Color containerElevated; -} - class M3FilterChip { const M3FilterChip({ required this.disabled, diff --git a/app/lib/rx_extension.dart b/app/lib/rx_extension.dart new file mode 100644 index 00000000..2cb9310f --- /dev/null +++ b/app/lib/rx_extension.dart @@ -0,0 +1,7 @@ +import 'package:rxdart/rxdart.dart'; + +extension BehaviorSubjectExtension on BehaviorSubject { + void addWithValue(T Function(T value) adder) { + add(adder(value)); + } +} diff --git a/app/lib/theme.dart b/app/lib/theme.dart index 1c07db02..dd5a231e 100644 --- a/app/lib/theme.dart +++ b/app/lib/theme.dart @@ -202,12 +202,6 @@ ThemeData _applyColorScheme(ColorScheme colorScheme, Color seedColor) { container: colorScheme.onSurface.withOpacity(.38), ), ), - assistChip: M3AssistChip( - enabled: M3AssistChipEnabled( - container: Colors.transparent, - containerElevated: colorScheme.surface, - ), - ), filterChip: M3FilterChip( disabled: M3FilterChipDisabled( containerSelected: colorScheme.onSurface.withOpacity(.12), diff --git a/app/lib/use_case/add_to_album.dart b/app/lib/use_case/album/add_file_to_album.dart similarity index 84% rename from app/lib/use_case/add_to_album.dart rename to app/lib/use_case/album/add_file_to_album.dart index 03fc7068..ff84878c 100644 --- a/app/lib/use_case/add_to_album.dart +++ b/app/lib/use_case/album/add_file_to_album.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/debug_util.dart'; @@ -10,30 +11,31 @@ import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/override_comparator.dart'; import 'package:nc_photos/use_case/create_share.dart'; +import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:np_codegen/np_codegen.dart'; -part 'add_to_album.g.dart'; +part 'add_file_to_album.g.dart'; @npLog -class AddToAlbum { - AddToAlbum(this._c) - : assert(require(_c)), - assert(ListShare.require(_c)), - assert(PreProcessAlbum.require(_c)); +class AddFileToAlbum { + AddFileToAlbum(this._c) : assert(require(_c)); static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo) && - DiContainer.has(c, DiType.shareRepo); + DiContainer.has(c, DiType.shareRepo) && + ListShare.require(c) && + PreProcessAlbum.require(c); - /// Add a list of AlbumItems to [album] + /// Add list of files to [album] Future call( - Account account, Album album, List items) async { - _log.info("[call] Add ${items.length} items to album '${album.name}'"); + Account account, Album album, List fds) async { + _log.info("[call] Add ${fds.length} items to album '${album.name}'"); assert(album.provider is AlbumStaticProvider); + final files = await InflateFileDescriptor(_c)(account, fds); // resync is needed to work out album cover and latest item final oldItems = await PreProcessAlbum(_c)(account, album); final itemSet = oldItems @@ -41,7 +43,12 @@ class AddToAlbum { e, _isItemFileEqual, _getItemHashCode)) .toSet(); // find the items that are not having the same file as any existing ones - final addItems = items + final addItems = files + .map((f) => AlbumFileItem( + addedBy: account.userId, + addedAt: clock.now(), + file: f, + )) .where((i) => itemSet.add(OverrideComparator( i, _isItemFileEqual, _getItemHashCode))) .toList(); diff --git a/app/lib/use_case/album/add_file_to_album.g.dart b/app/lib/use_case/album/add_file_to_album.g.dart new file mode 100644 index 00000000..36dad2ec --- /dev/null +++ b/app/lib/use_case/album/add_file_to_album.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'add_file_to_album.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$AddFileToAlbumNpLog on AddFileToAlbum { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("use_case.album.add_file_to_album.AddFileToAlbum"); +} diff --git a/app/lib/use_case/create_album.dart b/app/lib/use_case/album/create_album.dart similarity index 100% rename from app/lib/use_case/create_album.dart rename to app/lib/use_case/album/create_album.dart diff --git a/app/lib/use_case/album/edit_album.dart b/app/lib/use_case/album/edit_album.dart new file mode 100644 index 00000000..2ce141e0 --- /dev/null +++ b/app/lib/use_case/album/edit_album.dart @@ -0,0 +1,54 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/use_case/update_album.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'edit_album.g.dart'; + +@npLog +class EditAlbum { + const EditAlbum(this._c); + + /// Modify an [album] + Future call( + Account account, + Album album, { + String? name, + List? items, + CollectionItemSort? itemSort, + }) async { + _log.info( + "[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort"); + var newAlbum = album; + if (name != null) { + newAlbum = newAlbum.copyWith(name: name); + } + if (items != null) { + if (album.provider is AlbumStaticProvider) { + newAlbum = newAlbum.copyWith( + provider: (album.provider as AlbumStaticProvider).copyWith( + items: items, + ), + ); + } + } + if (itemSort != null) { + newAlbum = newAlbum.copyWith( + sortProvider: AlbumSortProvider.fromCollectionItemSort(itemSort), + ); + } + if (identical(newAlbum, album)) { + return album; + } + await UpdateAlbum(_c.albumRepo)(account, newAlbum); + return newAlbum; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/add_to_album.g.dart b/app/lib/use_case/album/edit_album.g.dart similarity index 66% rename from app/lib/use_case/add_to_album.g.dart rename to app/lib/use_case/album/edit_album.g.dart index 14b3ad1e..02ad4562 100644 --- a/app/lib/use_case/add_to_album.g.dart +++ b/app/lib/use_case/album/edit_album.g.dart @@ -1,14 +1,14 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'add_to_album.dart'; +part of 'edit_album.dart'; // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$AddToAlbumNpLog on AddToAlbum { +extension _$EditAlbumNpLog on EditAlbum { // ignore: unused_element Logger get _log => log; - static final log = Logger("use_case.add_to_album.AddToAlbum"); + static final log = Logger("use_case.album.edit_album.EditAlbum"); } diff --git a/app/lib/use_case/list_album.dart b/app/lib/use_case/album/list_album.dart similarity index 100% rename from app/lib/use_case/list_album.dart rename to app/lib/use_case/album/list_album.dart diff --git a/app/lib/use_case/album/list_album2.dart b/app/lib/use_case/album/list_album2.dart new file mode 100644 index 00000000..af9463c1 --- /dev/null +++ b/app/lib/use_case/album/list_album2.dart @@ -0,0 +1,78 @@ +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/use_case/compat/v15.dart'; +import 'package:nc_photos/use_case/compat/v25.dart'; +import 'package:nc_photos/use_case/ls.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'list_album2.g.dart'; + +@npLog +class ListAlbum2 { + ListAlbum2(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.albumRepo) && + DiContainer.has(c, DiType.fileRepo); + + Stream> call( + Account account, { + ErrorHandler? onError, + }) async* { + var hasAlbum = false; + try { + await for (final result in _call(account, onError: onError)) { + hasAlbum = true; + yield result; + } + } catch (e) { + if (e is ApiException && e.response.statusCode == 404) { + // no albums + return; + } else { + rethrow; + } + } + if (!hasAlbum) { + if (await CompatV15.migrateAlbumFiles(account, _c.fileRepo)) { + // migrated, try again + yield* _call(account); + } + } + } + + Stream> _call( + Account account, { + ErrorHandler? onError, + }) async* { + final ls = await Ls(_c.fileRepo)( + account, + File( + path: remote_storage_util.getRemoteAlbumsDir(account), + )); + final List albumFiles = + ls.where((element) => element.isCollection != true).toList(); + // migrate files + for (var i = 0; i < albumFiles.length; ++i) { + final f = albumFiles[i]!; + try { + if (CompatV25.isAlbumFileNeedMigration(f)) { + albumFiles[i] = await CompatV25.migrateAlbumFile(_c, account, f); + } + } catch (e, stackTrace) { + onError?.call(e, stackTrace); + albumFiles[i] = null; + } + } + yield* _c.albumRepo2.getAlbums(account, albumFiles.whereNotNull().toList()); + } + + final DiContainer _c; +} diff --git a/app/lib/widget/home_albums.g.dart b/app/lib/use_case/album/list_album2.g.dart similarity index 64% rename from app/lib/widget/home_albums.g.dart rename to app/lib/use_case/album/list_album2.g.dart index 96f164c6..c40a07ff 100644 --- a/app/lib/widget/home_albums.g.dart +++ b/app/lib/use_case/album/list_album2.g.dart @@ -1,14 +1,14 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'home_albums.dart'; +part of 'list_album2.dart'; // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$_HomeAlbumsStateNpLog on _HomeAlbumsState { +extension _$ListAlbum2NpLog on ListAlbum2 { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.home_albums._HomeAlbumsState"); + static final log = Logger("use_case.album.list_album2.ListAlbum2"); } diff --git a/app/lib/use_case/remove_album.dart b/app/lib/use_case/album/remove_album.dart similarity index 100% rename from app/lib/use_case/remove_album.dart rename to app/lib/use_case/album/remove_album.dart diff --git a/app/lib/use_case/remove_album.g.dart b/app/lib/use_case/album/remove_album.g.dart similarity index 82% rename from app/lib/use_case/remove_album.g.dart rename to app/lib/use_case/album/remove_album.g.dart index 1c9aea3a..5f1d70cb 100644 --- a/app/lib/use_case/remove_album.g.dart +++ b/app/lib/use_case/album/remove_album.g.dart @@ -10,5 +10,5 @@ extension _$RemoveAlbumNpLog on RemoveAlbum { // ignore: unused_element Logger get _log => log; - static final log = Logger("use_case.remove_album.RemoveAlbum"); + static final log = Logger("use_case.album.remove_album.RemoveAlbum"); } diff --git a/app/lib/use_case/remove_from_album.dart b/app/lib/use_case/album/remove_from_album.dart similarity index 80% rename from app/lib/use_case/remove_from_album.dart rename to app/lib/use_case/album/remove_from_album.dart index 55e0cec2..610a08d7 100644 --- a/app/lib/use_case/remove_from_album.dart +++ b/app/lib/use_case/album/remove_from_album.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; @@ -7,12 +8,14 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/use_case/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; part 'remove_from_album.g.dart'; @@ -37,19 +40,40 @@ class RemoveFromAlbum { Album album, List items, { bool shouldUnshare = true, + ErrorWithValueIndexedHandler? onError, }) async { _log.info("[call] Remove ${items.length} items from album '${album.name}'"); assert(album.provider is AlbumStaticProvider); + + final filtered = items + .mapIndexed((i, e) { + if (album.albumFile!.isOwned(account.userId) || + e.addedBy == account.userId) { + return e; + } else { + onError?.call( + i, + e, + const AlbumItemPermissionException( + "No permission to remove item"), + StackTrace.current, + ); + return null; + } + }) + .whereNotNull() + .toList(); final provider = album.provider as AlbumStaticProvider; final newItems = provider.items - .where((element) => !items.containsIdentical(element)) + .where((element) => !filtered.containsIdentical(element)) .toList(); var newAlbum = album.copyWith( provider: AlbumStaticProvider.of(album).copyWith( items: newItems, ), ); - newAlbum = await _fixAlbumPostRemove(account, newAlbum, items); + newAlbum = await _fixAlbumPostRemove(account, newAlbum, filtered); + // TODO catch and use onError await UpdateAlbum(_c.albumRepo)(account, newAlbum); if (!shouldUnshare) { @@ -57,7 +81,7 @@ class RemoveFromAlbum { } else { if (album.shares?.isNotEmpty == true) { final removeFiles = - items.whereType().map((e) => e.file).toList(); + filtered.whereType().map((e) => e.file).toList(); if (removeFiles.isNotEmpty) { await _unshareFiles(account, newAlbum, removeFiles); } diff --git a/app/lib/use_case/remove_from_album.g.dart b/app/lib/use_case/album/remove_from_album.g.dart similarity index 81% rename from app/lib/use_case/remove_from_album.g.dart rename to app/lib/use_case/album/remove_from_album.g.dart index 51256a52..65fa9ac0 100644 --- a/app/lib/use_case/remove_from_album.g.dart +++ b/app/lib/use_case/album/remove_from_album.g.dart @@ -10,5 +10,5 @@ extension _$RemoveFromAlbumNpLog on RemoveFromAlbum { // ignore: unused_element Logger get _log => log; - static final log = Logger("use_case.remove_from_album.RemoveFromAlbum"); + static final log = Logger("use_case.album.remove_from_album.RemoveFromAlbum"); } diff --git a/app/lib/use_case/archive_file.dart b/app/lib/use_case/archive_file.dart new file mode 100644 index 00000000..8498341f --- /dev/null +++ b/app/lib/use_case/archive_file.dart @@ -0,0 +1,72 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/use_case/update_property.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'archive_file.g.dart'; + +class ArchiveFile { + ArchiveFile(DiContainer c) : _op = _SetArchiveFile(c); + + /// Archive list of [files] and return the archived count + Future call( + Account account, + List files, { + ErrorWithValueHandler? onError, + }) => + _op(account, files, true, onError: onError); + + final _SetArchiveFile _op; +} + +class UnarchiveFile { + UnarchiveFile(DiContainer c) : _op = _SetArchiveFile(c); + + /// Unarchive list of [files] and return the unarchived count + Future call( + Account account, + List files, { + ErrorWithValueHandler? onError, + }) => + _op(account, files, false, onError: onError); + + final _SetArchiveFile _op; +} + +@npLog +class _SetArchiveFile { + _SetArchiveFile(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); + + /// Archive list of [files] and return the archived count + Future call( + Account account, + List files, + bool flag, { + ErrorWithValueHandler? onError, + }) async { + var count = 0; + for (final f in files) { + try { + await UpdateProperty(_c.fileRepo).updateIsArchived(account, f, flag); + ++count; + } catch (e, stackTrace) { + _log.severe( + "[call] Failed while UpdateProperty: ${logFilename(f.strippedPath)}", + e, + stackTrace, + ); + onError?.call(f, e, stackTrace); + } + } + return count; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/archive_file.g.dart b/app/lib/use_case/archive_file.g.dart new file mode 100644 index 00000000..e04ace9a --- /dev/null +++ b/app/lib/use_case/archive_file.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'archive_file.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_SetArchiveFileNpLog on _SetArchiveFile { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("use_case.archive_file._SetArchiveFile"); +} diff --git a/app/lib/use_case/collection/add_file_to_collection.dart b/app/lib/use_case/collection/add_file_to_collection.dart new file mode 100644 index 00000000..778c54b7 --- /dev/null +++ b/app/lib/use_case/collection/add_file_to_collection.dart @@ -0,0 +1,28 @@ +import 'package:flutter/rendering.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_common/type.dart'; + +class AddFileToCollection { + const AddFileToCollection(this._c); + + /// Add list of [files] to [collection] and return the added count + Future call( + Account account, + Collection collection, + List files, { + ErrorWithValueHandler? onError, + required ValueChanged onCollectionUpdated, + }) { + return CollectionAdapter.of(_c, account, collection).addFiles( + files, + onError: onError, + onCollectionUpdated: onCollectionUpdated, + ); + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/create_collection.dart b/app/lib/use_case/collection/create_collection.dart new file mode 100644 index 00000000..de4ff582 --- /dev/null +++ b/app/lib/use_case/collection/create_collection.dart @@ -0,0 +1,34 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/use_case/album/create_album.dart'; +import 'package:nc_photos/use_case/nc_album/create_nc_album.dart'; + +class CreateCollection { + CreateCollection(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.albumRepo) && CreateNcAlbum.require(c); + + Future call(Account account, Collection collection) async { + final provider = collection.contentProvider; + if (provider is CollectionNcAlbumProvider) { + await CreateNcAlbum(_c)(account, provider.album); + return collection; + } else if (provider is CollectionAlbumProvider) { + final album = await CreateAlbum(_c.albumRepo)(account, provider.album); + return collection.copyWith( + contentProvider: CollectionAlbumProvider( + account: account, + album: album, + ), + ); + } else { + throw UnimplementedError("Unknown type: ${provider.runtimeType}"); + } + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/edit_collection.dart b/app/lib/use_case/collection/edit_collection.dart new file mode 100644 index 00000000..19d584b7 --- /dev/null +++ b/app/lib/use_case/collection/edit_collection.dart @@ -0,0 +1,34 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; + +class EditCollection { + const EditCollection(this._c); + + /// Edit a [collection] + /// + /// This use case support the following operations (implementations may only + /// support a subset of the below operations): + /// - Rename (set [name]) + /// - Add text label(s) (set [items]) + /// - Sort [items] (set [items] and/or [itemSort]) + /// + /// \* To add files to a collection, use [AddFileToCollection] instead + Future call( + Account account, + Collection collection, { + String? name, + List? items, + CollectionItemSort? itemSort, + }) => + CollectionAdapter.of(_c, account, collection).edit( + name: name, + items: items, + itemSort: itemSort, + ); + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/list_collection.dart b/app/lib/use_case/collection/list_collection.dart new file mode 100644 index 00000000..633730d2 --- /dev/null +++ b/app/lib/use_case/collection/list_collection.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/builder.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/use_case/album/list_album2.dart'; +import 'package:nc_photos/use_case/nc_album/list_nc_album.dart'; + +class ListCollection { + ListCollection(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.albumRepo2) && + DiContainer.has(c, DiType.ncAlbumRepo); + + Stream> call(Account account) { + final controller = StreamController>(); + var albums = []; + var isAlbumDone = false; + var ncAlbums = []; + var isNcAlbumDone = false; + + void notify() { + controller.add([ + ...albums.map((a) => CollectionBuilder.byAlbum(account, a)), + ...ncAlbums.map((a) => CollectionBuilder.byNcAlbum(account, a)), + ]); + } + + void onDone() { + if (isAlbumDone && isNcAlbumDone) { + controller.close(); + } + } + + ListAlbum2(_c)(account).listen( + (event) { + albums = event; + notify(); + }, + onDone: () { + isAlbumDone = true; + onDone(); + }, + onError: (e, stackTrace) { + controller.addError(e, stackTrace); + isAlbumDone = true; + onDone(); + }, + ); + ListNcAlbum(_c)(account).listen( + (event) { + ncAlbums = event; + notify(); + }, + onDone: () { + isNcAlbumDone = true; + onDone(); + }, + onError: (e, stackTrace) { + controller.addError(e, stackTrace); + isNcAlbumDone = true; + onDone(); + }, + ); + return controller.stream; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/list_collection_item.dart b/app/lib/use_case/collection/list_collection_item.dart new file mode 100644 index 00000000..b5d9e180 --- /dev/null +++ b/app/lib/use_case/collection/list_collection_item.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'list_collection_item.g.dart'; + +@npLog +class ListCollectionItem { + const ListCollectionItem(this._c); + + Stream> call(Account account, Collection collection) => + CollectionAdapter.of(_c, account, collection).listItem(); + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/list_collection_item.g.dart b/app/lib/use_case/collection/list_collection_item.g.dart new file mode 100644 index 00000000..433eaa54 --- /dev/null +++ b/app/lib/use_case/collection/list_collection_item.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'list_collection_item.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$ListCollectionItemNpLog on ListCollectionItem { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.collection.list_collection_item.ListCollectionItem"); +} diff --git a/app/lib/use_case/collection/remove_collections.dart b/app/lib/use_case/collection/remove_collections.dart new file mode 100644 index 00000000..8c09b622 --- /dev/null +++ b/app/lib/use_case/collection/remove_collections.dart @@ -0,0 +1,43 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'remove_collections.g.dart'; + +@npLog +class RemoveCollections { + RemoveCollections(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => true; + + /// Remove [collections] and return the removed count + /// + /// If [onError] is not null, you'll get notified about the errors. The future + /// will always complete normally + Future call( + Account account, + List collections, { + ErrorWithValueHandler? onError, + }) async { + var failed = 0; + final futures = Future.wait(collections.map((c) { + return CollectionAdapter.of(_c, account, c) + .remove() + .catchError((e, stackTrace) { + ++failed; + onError?.call(c, e, stackTrace); + }); + })); + await futures; + if (failed > 0) { + _log.warning("[call] Failed removing $failed collections"); + } + return collections.length - failed; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/remove_collections.g.dart b/app/lib/use_case/collection/remove_collections.g.dart new file mode 100644 index 00000000..bf75eb0e --- /dev/null +++ b/app/lib/use_case/collection/remove_collections.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'remove_collections.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$RemoveCollectionsNpLog on RemoveCollections { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.collection.remove_collections.RemoveCollections"); +} diff --git a/app/lib/use_case/collection/remove_from_collection.dart b/app/lib/use_case/collection/remove_from_collection.dart new file mode 100644 index 00000000..45ac205a --- /dev/null +++ b/app/lib/use_case/collection/remove_from_collection.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:np_common/type.dart'; + +class RemoveFromCollection { + const RemoveFromCollection(this._c); + + Future call( + Account account, + Collection collection, + List items, { + ErrorWithValueIndexedHandler? onError, + required ValueChanged onCollectionUpdated, + }) { + return CollectionAdapter.of(_c, account, collection).removeItems( + items, + onError: onError, + onCollectionUpdated: onCollectionUpdated, + ); + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/download_file.dart b/app/lib/use_case/download_file.dart index 7c29f8d3..a932672c 100644 --- a/app/lib/use_case/download_file.dart +++ b/app/lib/use_case/download_file.dart @@ -1,5 +1,4 @@ import 'package:nc_photos/account.dart'; -import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/mobile/platform.dart' if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; @@ -10,17 +9,17 @@ class DownloadFile { /// Create a new download but don't start it yet Download build( Account account, - File file, { + FileDescriptor file, { String? parentDir, bool? shouldNotify, }) { - final url = "${account.url}/${file.path}"; + final url = "${account.url}/${file.fdPath}"; return platform.DownloadBuilder().build( url: url, headers: { "authorization": AuthUtil.fromAccount(account).toHeaderValue(), }, - mimeType: file.contentType, + mimeType: file.fdMime, filename: file.filename, parentDir: parentDir, shouldNotify: shouldNotify, @@ -32,7 +31,7 @@ class DownloadFile { /// See [DownloadBuilder] Future call( Account account, - File file, { + FileDescriptor file, { String? parentDir, bool? shouldNotify, }) => diff --git a/app/lib/use_case/download_preview.dart b/app/lib/use_case/download_preview.dart index 8d3c3c5b..9b55864f 100644 --- a/app/lib/use_case/download_preview.dart +++ b/app/lib/use_case/download_preview.dart @@ -1,14 +1,14 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/cache_manager_util.dart'; -import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos_plugin/nc_photos_plugin.dart'; class DownloadPreview { - Future call(Account account, File file) async { + Future call(Account account, FileDescriptor file) async { assert(platform_k.isAndroid); final previewUrl = api_util.getFilePreviewUrl( account, diff --git a/app/lib/use_case/find_file_descriptor.dart b/app/lib/use_case/find_file_descriptor.dart new file mode 100644 index 00000000..559110f8 --- /dev/null +++ b/app/lib/use_case/find_file_descriptor.dart @@ -0,0 +1,54 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'find_file_descriptor.g.dart'; + +@npLog +class FindFileDescriptor { + FindFileDescriptor(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); + + /// Find list of files in the DB by [fileIds] + /// + /// If an id is not found, [onFileNotFound] will be called. If + /// [onFileNotFound] is null, a [StateError] will be thrown + Future> call( + Account account, + List fileIds, { + void Function(int fileId)? onFileNotFound, + }) async { + _log.info("[call] fileIds: ${fileIds.toReadableString()}"); + final dbFiles = await _c.sqliteDb.use((db) async { + return await db.fileDescriptorsByFileIds( + sql.ByAccount.app(account), fileIds); + }); + final files = dbFiles.convertToAppFileDescriptor(account); + final fileMap = {}; + for (final f in files) { + fileMap[f.fdId] = f; + } + + final results = []; + for (final id in fileIds) { + final f = fileMap[id]; + if (f == null) { + if (onFileNotFound == null) { + throw StateError("File ID not found: $id"); + } else { + onFileNotFound(id); + } + } else { + results.add(f); + } + } + return results; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/find_file_descriptor.g.dart b/app/lib/use_case/find_file_descriptor.g.dart new file mode 100644 index 00000000..3e341a14 --- /dev/null +++ b/app/lib/use_case/find_file_descriptor.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'find_file_descriptor.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$FindFileDescriptorNpLog on FindFileDescriptor { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("use_case.find_file_descriptor.FindFileDescriptor"); +} diff --git a/app/lib/use_case/list_face.dart b/app/lib/use_case/list_face.dart new file mode 100644 index 00000000..97441182 --- /dev/null +++ b/app/lib/use_case/list_face.dart @@ -0,0 +1,15 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/face.dart'; +import 'package:nc_photos/entity/person.dart'; + +class ListFace { + ListFace(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.faceRepo); + + Future> call(Account account, Person person) => + _c.faceRepo.list(account, person); + + final DiContainer _c; +} diff --git a/app/lib/use_case/list_favorite.dart b/app/lib/use_case/list_favorite.dart deleted file mode 100644 index 7dba4612..00000000 --- a/app/lib/use_case/list_favorite.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/favorite.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/use_case/find_file.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'list_favorite.g.dart'; - -@npLog -class ListFavorite { - ListFavorite(this._c) - : assert(require(_c)), - assert(FindFile.require(_c)); - - static bool require(DiContainer c) => DiContainer.has(c, DiType.favoriteRepo); - - /// List all favorites for [account] - Future> call(Account account) async { - final favorites = []; - for (final r in account.roots) { - favorites.addAll(await _c.favoriteRepo - .list(account, File(path: file_util.unstripPath(account, r)))); - } - final files = await FindFile(_c)( - account, - favorites.map((f) => f.fileId).toList(), - onFileNotFound: (id) { - // ignore missing file - _log.warning("[call] Missing file: $id"); - }, - ); - return files - .where((f) => file_util.isSupportedFormat(f)) - // The file in AppDb may not be marked as favorite correctly - .map((f) => f.copyWith(isFavorite: true)) - .toList(); - } - - final DiContainer _c; -} diff --git a/app/lib/use_case/list_favorite_offline.dart b/app/lib/use_case/list_favorite_offline.dart deleted file mode 100644 index 6505495f..00000000 --- a/app/lib/use_case/list_favorite_offline.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/sqlite/database.dart' as sql; - -class ListFavoriteOffline { - ListFavoriteOffline(this._c) : assert(require(_c)); - - static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); - - /// List all favorites for [account] from the local DB - Future> call(Account account) async { - final dbFiles = await _c.sqliteDb.use((db) async { - return await db.completeFilesByFavorite(appAccount: account); - }); - return await dbFiles.convertToAppFile(account); - } - - final DiContainer _c; -} diff --git a/app/lib/use_case/nc_album/add_file_to_nc_album.dart b/app/lib/use_case/nc_album/add_file_to_nc_album.dart new file mode 100644 index 00000000..fb34a12a --- /dev/null +++ b/app/lib/use_case/nc_album/add_file_to_nc_album.dart @@ -0,0 +1,44 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/use_case/copy.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'add_file_to_nc_album.g.dart'; + +@npLog +class AddFileToNcAlbum { + AddFileToNcAlbum(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); + + /// Add list of [files] to [album] and return the added count + Future call( + Account account, + NcAlbum album, + List files, { + ErrorWithValueHandler? onError, + }) async { + _log.info( + "[call] Add ${files.length} items to album '${album.strippedPath}'"); + var count = 0; + for (final f in files) { + try { + await Copy(_c.fileRepo)( + account, + f.toFile(), + "${album.path}/${f.fdPath.split("/").last}", + ); + ++count; + } catch (e, stackTrace) { + onError?.call(f, e, stackTrace); + } + } + return count; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/nc_album/add_file_to_nc_album.g.dart b/app/lib/use_case/nc_album/add_file_to_nc_album.g.dart new file mode 100644 index 00000000..ad40e829 --- /dev/null +++ b/app/lib/use_case/nc_album/add_file_to_nc_album.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'add_file_to_nc_album.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$AddFileToNcAlbumNpLog on AddFileToNcAlbum { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.nc_album.add_file_to_nc_album.AddFileToNcAlbum"); +} diff --git a/app/lib/use_case/nc_album/create_nc_album.dart b/app/lib/use_case/nc_album/create_nc_album.dart new file mode 100644 index 00000000..3ff64891 --- /dev/null +++ b/app/lib/use_case/nc_album/create_nc_album.dart @@ -0,0 +1,14 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/nc_album.dart'; + +class CreateNcAlbum { + CreateNcAlbum(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.ncAlbumRepo); + + Future call(Account account, NcAlbum album) => + _c.ncAlbumRepo.create(account, album); + + final DiContainer _c; +} diff --git a/app/lib/use_case/nc_album/edit_nc_album.dart b/app/lib/use_case/nc_album/edit_nc_album.dart new file mode 100644 index 00000000..3231c2e0 --- /dev/null +++ b/app/lib/use_case/nc_album/edit_nc_album.dart @@ -0,0 +1,37 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/use_case/move.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'edit_nc_album.g.dart'; + +@npLog +class EditNcAlbum { + const EditNcAlbum(this._c); + + Future call( + Account account, + NcAlbum album, { + String? name, + List? items, + CollectionItemSort? itemSort, + }) async { + var newAlbum = album; + if (items != null || itemSort != null) { + _log.severe("[call] Editing items/itemSort is not supported"); + } + if (name != null) { + final newPath = album.getRenamedPath(name); + await Move(_c)(account, File(path: album.path), newPath); + newAlbum = newAlbum.copyWith(path: newPath); + } + return newAlbum; + } + + final DiContainer _c; +} diff --git a/app/lib/widget/tag_browser.g.dart b/app/lib/use_case/nc_album/edit_nc_album.g.dart similarity index 64% rename from app/lib/widget/tag_browser.g.dart rename to app/lib/use_case/nc_album/edit_nc_album.g.dart index 7a2faedb..12391f53 100644 --- a/app/lib/widget/tag_browser.g.dart +++ b/app/lib/use_case/nc_album/edit_nc_album.g.dart @@ -1,14 +1,14 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'tag_browser.dart'; +part of 'edit_nc_album.dart'; // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$_TagBrowserStateNpLog on _TagBrowserState { +extension _$EditNcAlbumNpLog on EditNcAlbum { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.tag_browser._TagBrowserState"); + static final log = Logger("use_case.nc_album.edit_nc_album.EditNcAlbum"); } diff --git a/app/lib/use_case/nc_album/list_nc_album.dart b/app/lib/use_case/nc_album/list_nc_album.dart new file mode 100644 index 00000000..4a43728f --- /dev/null +++ b/app/lib/use_case/nc_album/list_nc_album.dart @@ -0,0 +1,15 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/nc_album.dart'; + +class ListNcAlbum { + ListNcAlbum(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.ncAlbumRepo); + + /// List all [NcAlbum]s belonging to [account] + Stream> call(Account account) => + _c.ncAlbumRepo.getAlbums(account); + + final DiContainer _c; +} diff --git a/app/lib/use_case/nc_album/list_nc_album_item.dart b/app/lib/use_case/nc_album/list_nc_album_item.dart new file mode 100644 index 00000000..43f6de21 --- /dev/null +++ b/app/lib/use_case/nc_album/list_nc_album_item.dart @@ -0,0 +1,15 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/entity/nc_album/item.dart'; + +class ListNcAlbumItem { + ListNcAlbumItem(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.ncAlbumRepo); + + Stream> call(Account account, NcAlbum album) => + _c.ncAlbumRepo.getItems(account, album); + + final DiContainer _c; +} diff --git a/app/lib/use_case/nc_album/remove_from_nc_album.dart b/app/lib/use_case/nc_album/remove_from_nc_album.dart new file mode 100644 index 00000000..dd3a660c --- /dev/null +++ b/app/lib/use_case/nc_album/remove_from_nc_album.dart @@ -0,0 +1,67 @@ +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/basic_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/use_case/remove.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'remove_from_nc_album.g.dart'; + +@npLog +class RemoveFromNcAlbum { + RemoveFromNcAlbum(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); + + Future call( + Account account, + NcAlbum album, + List items, { + ErrorWithValueIndexedHandler? onError, + }) async { + _log.info( + "[call] Remove ${items.length} items from album '${album.strippedPath}'"); + final fileItems = items + .whereIndexed((i, e) { + if (e is! BasicCollectionFileItem) { + onError?.call( + i, + e, + UnsupportedError("Item type not supported: ${e.runtimeType}"), + StackTrace.current, + ); + return false; + } else { + return true; + } + }) + .cast() + .toList(); + var count = fileItems.length; + await Remove(_c)( + account, + fileItems.map((e) => e.file.toFile()).toList(), + onError: (i, f, e, stackTrace) { + --count; + try { + onError?.call(i, fileItems[i], e, stackTrace); + } catch (e, stackTrace) { + _log.severe( + "[call] Failed file not found: ${logFilename(f.strippedPath)}", + e, + stackTrace, + ); + } + }, + ); + return count; + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/nc_album/remove_from_nc_album.g.dart b/app/lib/use_case/nc_album/remove_from_nc_album.g.dart new file mode 100644 index 00000000..6f9d5997 --- /dev/null +++ b/app/lib/use_case/nc_album/remove_from_nc_album.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'remove_from_nc_album.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$RemoveFromNcAlbumNpLog on RemoveFromNcAlbum { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.nc_album.remove_from_nc_album.RemoveFromNcAlbum"); +} diff --git a/app/lib/use_case/nc_album/remove_nc_album.dart b/app/lib/use_case/nc_album/remove_nc_album.dart new file mode 100644 index 00000000..9783e154 --- /dev/null +++ b/app/lib/use_case/nc_album/remove_nc_album.dart @@ -0,0 +1,14 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/nc_album.dart'; + +class RemoveNcAlbum { + RemoveNcAlbum(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.ncAlbumRepo); + + Future call(Account account, NcAlbum album) => + _c.ncAlbumRepo.remove(account, album); + + final DiContainer _c; +} diff --git a/app/lib/use_case/remove.dart b/app/lib/use_case/remove.dart index a262e9c8..ee226ab4 100644 --- a/app/lib/use_case/remove.dart +++ b/app/lib/use_case/remove.dart @@ -12,12 +12,13 @@ import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/stream_extension.dart'; -import 'package:nc_photos/use_case/list_album.dart'; +import 'package:nc_photos/use_case/album/list_album.dart'; +import 'package:nc_photos/use_case/album/remove_from_album.dart'; import 'package:nc_photos/use_case/list_share.dart'; -import 'package:nc_photos/use_case/remove_from_album.dart'; import 'package:nc_photos/use_case/remove_share.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'remove.g.dart'; @@ -33,12 +34,11 @@ class Remove { DiContainer.has(c, DiType.fileRepo) && DiContainer.has(c, DiType.shareRepo); - /// Remove files - Future call( + /// Remove list of [files] and return the removed count + Future call( Account account, - List files, { - void Function(File file, Object error, StackTrace stackTrace)? - onRemoveFileFailed, + List files, { + ErrorWithValueIndexedHandler? onError, bool shouldCleanUp = true, }) async { // need to cleanup first, otherwise we can't unshare the files @@ -47,19 +47,25 @@ class Remove { } else { await _cleanUpAlbums(account, files); } - for (final f in files) { + var count = 0; + for (final pair in files.withIndex()) { + final i = pair.item1; + final f = pair.item2; try { await _c.fileRepo.remove(account, f); + ++count; KiwiContainer().resolve().fire(FileRemovedEvent(account, f)); } catch (e, stackTrace) { - _log.severe("[call] Failed while remove: ${logFilename(f.path)}", e, + _log.severe("[call] Failed while remove: ${logFilename(f.fdPath)}", e, stackTrace); - onRemoveFileFailed?.call(f, e, stackTrace); + onError?.call(i, f, e, stackTrace); } } + return count; } - Future _cleanUpAlbums(Account account, List removes) async { + Future _cleanUpAlbums( + Account account, List removes) async { final albums = await ListAlbum(_c)(account).whereType().toList(); // figure out which files need to be unshared with whom final unshares = >{}; diff --git a/app/lib/use_case/unshare_file_from_album.dart b/app/lib/use_case/unshare_file_from_album.dart index e2b8d3ac..94c609cf 100644 --- a/app/lib/use_case/unshare_file_from_album.dart +++ b/app/lib/use_case/unshare_file_from_album.dart @@ -9,7 +9,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/stream_extension.dart'; -import 'package:nc_photos/use_case/list_album.dart'; +import 'package:nc_photos/use_case/album/list_album.dart'; import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/remove_share.dart'; import 'package:np_codegen/np_codegen.dart'; diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index 30b62c26..13cb10c9 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -27,16 +27,16 @@ import 'package:nc_photos/pref.dart'; import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/use_case/album/remove_from_album.dart'; import 'package:nc_photos/use_case/ls_single_file.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; -import 'package:nc_photos/use_case/remove_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:nc_photos/widget/album_browser_mixin.dart'; import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/draggable_item_list_mixin.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; +import 'package:nc_photos/widget/handler/add_selection_to_collection_handler.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; @@ -438,10 +438,8 @@ class _AlbumBrowserState extends State } Future _onSelectionAddPressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( + return const AddSelectionToCollectionHandler()( context: context, - account: widget.account, selection: selectedListItems .whereType<_FileListItem>() .map((e) => e.file) @@ -718,10 +716,12 @@ class _AlbumBrowserState extends State void _transformItems() { if (_editAlbum != null) { // edit mode - _sortedItems = - _editAlbum!.sortProvider.sort(_getAlbumItemsOf(_editAlbum!)); + // _sortedItems = + // _editAlbum!.sortProvider.sort(_getAlbumItemsOf(_editAlbum!)); + _sortedItems = _getAlbumItemsOf(_editAlbum!); } else { - _sortedItems = _album!.sortProvider.sort(_getAlbumItemsOf(_album!)); + // _sortedItems = _album!.sortProvider.sort(_getAlbumItemsOf(_album!)); + _sortedItems = _getAlbumItemsOf(_album!); } _backingFiles = _sortedItems .whereType() diff --git a/app/lib/widget/album_importer.dart b/app/lib/widget/album_importer.dart index 7243410c..a54ad28f 100644 --- a/app/lib/widget/album_importer.dart +++ b/app/lib/widget/album_importer.dart @@ -20,7 +20,7 @@ import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/use_case/create_album.dart'; +import 'package:nc_photos/use_case/album/create_album.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:nc_photos/widget/processing_dialog.dart'; diff --git a/app/lib/widget/album_picker.dart b/app/lib/widget/album_picker.dart deleted file mode 100644 index eb70360e..00000000 --- a/app/lib/widget/album_picker.dart +++ /dev/null @@ -1,278 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/bloc/list_album.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/album_util.dart' as album_util; -import 'package:nc_photos/exception_util.dart' as exception_util; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/widget/album_grid_item.dart'; -import 'package:nc_photos/widget/builder/album_grid_item_builder.dart'; -import 'package:nc_photos/widget/new_album_dialog.dart'; -import 'package:nc_photos/widget/page_visibility_mixin.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'album_picker.g.dart'; - -class AlbumPickerArguments { - AlbumPickerArguments(this.account); - - final Account account; -} - -class AlbumPicker extends StatefulWidget { - static const routeName = "/album-picker"; - - static Route buildRoute(AlbumPickerArguments args) => - MaterialPageRoute( - builder: (context) => AlbumPicker.fromArgs(args), - ); - - const AlbumPicker({ - Key? key, - required this.account, - }) : super(key: key); - - AlbumPicker.fromArgs(AlbumPickerArguments args, {Key? key}) - : this( - key: key, - account: args.account, - ); - - @override - createState() => _AlbumPickerState(); - - final Account account; -} - -@npLog -class _AlbumPickerState extends State - with RouteAware, PageVisibilityMixin { - @override - initState() { - super.initState(); - _initBloc(); - } - - @override - build(BuildContext context) { - return Scaffold( - body: BlocConsumer( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - builder: (context, state) => _buildContent(context, state), - ), - ); - } - - void _initBloc() { - if (_bloc.state is ListAlbumBlocInit) { - _log.info("[_initBloc] Initialize bloc"); - _reqQuery(); - } else { - // process the current state - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _onStateChange(context, _bloc.state); - }); - } - }); - } - } - - Widget _buildContent(BuildContext context, ListAlbumBlocState state) { - return CustomScrollView( - slivers: [ - SliverAppBar( - title: Text(L10n.global().addToAlbumTooltip), - floating: true, - ), - if (state is ListAlbumBlocLoading) - const SliverToBoxAdapter( - child: LinearProgressIndicator(), - ), - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverStaggeredGrid.extentBuilder( - maxCrossAxisExtent: 256, - mainAxisSpacing: 8, - staggeredTileBuilder: (_) => const StaggeredTile.count(1, 1), - itemBuilder: (context, index) { - if (index == 0) { - return _buildNewAlbumItem(context); - } else { - return _buildItem(context, index - 1); - } - }, - itemCount: _sortedAlbums.length + 1, - ), - ), - ], - ); - } - - Widget _buildNewAlbumItem(BuildContext context) { - return _NewAlbumView( - onTap: () => _onNewAlbumPressed(context), - ); - } - - Widget _buildItem(BuildContext context, int index) { - final item = _sortedAlbums[index]; - return _AlbumView( - account: widget.account, - album: item, - onTap: () => _onItemPressed(context, item), - ); - } - - void _onStateChange(BuildContext context, ListAlbumBlocState state) { - if (state is ListAlbumBlocInit) { - _sortedAlbums = []; - } else if (state is ListAlbumBlocSuccess || state is ListAlbumBlocLoading) { - _transformItems(state.items); - } else if (state is ListAlbumBlocFailure) { - _transformItems(state.items); - if (isPageVisible()) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } - } else if (state is ListAlbumBlocInconsistent) { - _reqQuery(); - } - } - - Future _onNewAlbumPressed(BuildContext context) async { - try { - final album = await showDialog( - context: context, - builder: (_) => NewAlbumDialog( - account: widget.account, - isAllowDynamic: false, - ), - ); - if (album == null) { - // user canceled - return; - } - Navigator.of(context).pop(album); - } catch (e, stacktrace) { - _log.shout("[_onNewAlbumPressed] Failed while showDialog", e, stacktrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onItemPressed(BuildContext context, Album album) { - Navigator.of(context).pop(album); - } - - void _transformItems(List items) { - _sortedAlbums = album_util.sorted( - items - .map((e) => e.album) - .where((a) => a.provider is AlbumStaticProvider) - .toList(), - _getSortFromPref()); - } - - void _reqQuery() { - _bloc.add(ListAlbumBlocQuery(widget.account)); - } - - late final _bloc = ListAlbumBloc.of(widget.account); - var _sortedAlbums = []; -} - -class _NewAlbumView extends StatelessWidget { - const _NewAlbumView({ - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - AlbumGridItem( - cover: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Theme.of(context).listPlaceholderBackgroundColor, - constraints: const BoxConstraints.expand(), - child: Icon( - Icons.add, - color: Theme.of(context).listPlaceholderForegroundColor, - size: 88, - ), - ), - ), - title: L10n.global().createAlbumTooltip, - ), - Positioned.fill( - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onTap, - ), - ), - ), - ], - ); - } - - final VoidCallback? onTap; -} - -class _AlbumView extends StatelessWidget { - const _AlbumView({ - required this.account, - required this.album, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - AlbumGridItemBuilder( - account: account, - album: album, - isShared: album.shares?.isNotEmpty == true, - ).build(context), - Positioned.fill( - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onTap, - ), - ), - ), - ], - ); - } - - final Account account; - final Album album; - final VoidCallback? onTap; -} - -album_util.AlbumSort _getSortFromPref() { - try { - return album_util.AlbumSort.values[Pref().getHomeAlbumsSort()!]; - } catch (_) { - // default - return album_util.AlbumSort.dateDescending; - } -} diff --git a/app/lib/widget/builder/album_grid_item_builder.dart b/app/lib/widget/builder/album_grid_item_builder.dart deleted file mode 100644 index bdb1456e..00000000 --- a/app/lib/widget/builder/album_grid_item_builder.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; -import 'package:flutter/material.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/cache_manager_util.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/np_api_util.dart'; -import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/widget/album_grid_item.dart'; - -/// Build a standard [AlbumGridItem] for an [Album] -class AlbumGridItemBuilder { - AlbumGridItemBuilder({ - required this.account, - required this.album, - this.isShared = false, - }); - - AlbumGridItem build(BuildContext context) { - var subtitle = ""; - String? subtitle2; - IconData? icon; - if (album.provider is AlbumStaticProvider) { - subtitle = - L10n.global().albumSize(AlbumStaticProvider.of(album).items.length); - } else if (album.provider is AlbumDirProvider) { - final provider = album.provider as AlbumDirProvider; - subtitle = provider.dirs.first.strippedPath; - if (provider.dirs.length > 1) { - subtitle2 = "+${provider.dirs.length - 1}"; - } - icon = Icons.folder; - } else if (album.provider is AlbumTagProvider) { - final provider = album.provider as AlbumTagProvider; - subtitle = provider.tags.map((t) => t.displayName).join(", "); - icon = Icons.local_offer; - } - if (isShared) { - subtitle = "${L10n.global().albumSharedLabel} | $subtitle"; - } - return AlbumGridItem( - cover: _buildAlbumCover(context, album), - title: album.name, - subtitle: subtitle, - subtitle2: subtitle2, - icon: icon, - ); - } - - Widget _buildAlbumCover(BuildContext context, Album album) { - Widget cover; - try { - final coverFile = album.coverProvider.getCover(album); - final previewUrl = api_util.getFilePreviewUrl(account, coverFile!, - width: k.coverSize, height: k.coverSize, isKeepAspectRatio: false); - cover = FittedBox( - clipBehavior: Clip.hardEdge, - fit: BoxFit.cover, - child: CachedNetworkImage( - cacheManager: CoverCacheManager.inst, - imageUrl: previewUrl, - httpHeaders: { - "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), - }, - fadeInDuration: const Duration(), - filterQuality: FilterQuality.high, - errorWidget: (context, url, error) { - // just leave it empty - return Container(); - }, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, - ), - ); - } catch (_) { - cover = Icon( - Icons.panorama, - color: Theme.of(context).listPlaceholderForegroundColor, - size: 88, - ); - } - - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Theme.of(context).listPlaceholderBackgroundColor, - constraints: const BoxConstraints.expand(), - child: cover, - ), - ); - } - - final Account account; - final Album album; - final bool isShared; -} diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart new file mode 100644 index 00000000..eefee6f9 --- /dev/null +++ b/app/lib/widget/collection_browser.dart @@ -0,0 +1,373 @@ +import 'dart:async'; + +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; +import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/collection_items_controller.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/download_handler.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/new_item.dart'; +import 'package:nc_photos/entity/collection_item/sorter.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/flutter_util.dart' as flutter_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/np_api_util.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/use_case/archive_file.dart'; +import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; +import 'package:nc_photos/use_case/remove.dart'; +import 'package:nc_photos/widget/collection_picker.dart'; +import 'package:nc_photos/widget/draggable_item_list.dart'; +import 'package:nc_photos/widget/fancy_option_picker.dart'; +import 'package:nc_photos/widget/file_sharer.dart'; +import 'package:nc_photos/widget/network_thumbnail.dart'; +import 'package:nc_photos/widget/page_visibility_mixin.dart'; +import 'package:nc_photos/widget/photo_list_item.dart'; +import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; +import 'package:nc_photos/widget/selectable_item_list.dart'; +import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos/widget/simple_input_dialog.dart'; +import 'package:nc_photos/widget/viewer.dart'; +import 'package:nc_photos/widget/zoom_menu_button.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'collection_browser.g.dart'; +part 'collection_browser/app_bar.dart'; +part 'collection_browser/bloc.dart'; +part 'collection_browser/state_event.dart'; +part 'collection_browser/type.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; + +class CollectionBrowserArguments { + const CollectionBrowserArguments(this.collection); + + final Collection collection; +} + +/// Browse the content of a collection +class CollectionBrowser extends StatelessWidget { + static const routeName = "/collection-browser"; + + static Route buildRoute(CollectionBrowserArguments args) => MaterialPageRoute( + builder: (context) => CollectionBrowser.fromArgs(args), + ); + + const CollectionBrowser({ + super.key, + required this.collection, + }); + + CollectionBrowser.fromArgs( + CollectionBrowserArguments args, { + Key? key, + }) : this( + key: key, + collection: args.collection, + ); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _Bloc( + container: KiwiContainer().resolve(), + account: context.read().account, + collectionsController: + context.read().collectionsController, + collection: collection, + ), + child: const _WrappedCollectionBrowser(), + ); + } + + final Collection collection; +} + +class _WrappedCollectionBrowser extends StatefulWidget { + const _WrappedCollectionBrowser(); + + @override + State createState() => _WrappedCollectionBrowserState(); +} + +@npLog +class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> + with RouteAware, PageVisibilityMixin { + @override + void initState() { + super.initState(); + _bloc.add(const _LoadItems()); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + if (_bloc.state.isEditMode) { + _bloc.add(const _CancelEdit()); + return false; + } else if (_bloc.state.selectedItems.isNotEmpty) { + _bloc.add(const _SetSelectedItems(items: {})); + return false; + } else { + return true; + } + }, + child: Scaffold( + body: MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.items != current.items, + listener: (context, state) { + _bloc.add(_TransformItems( + items: state.items, + )); + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.editItems != current.editItems, + listener: (context, state) { + if (state.editItems != null) { + _bloc.add(_TransformEditItems( + items: state.editItems!, + )); + } + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.error != current.error, + listener: (context, state) { + if (state.error != null && isPageVisible()) { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.message != current.message, + listener: (context, state) { + if (state.message != null && isPageVisible()) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(state.message!), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: Stack( + fit: StackFit.expand, + children: [ + CustomScrollView( + slivers: [ + _BlocBuilder( + buildWhen: (previous, current) => + previous.selectedItems.isEmpty != + current.selectedItems.isEmpty || + previous.isEditMode != current.isEditMode, + builder: (context, state) { + if (state.isEditMode) { + return const _EditAppBar(); + } else if (state.selectedItems.isNotEmpty) { + return const _SelectionAppBar(); + } else { + return const _AppBar(); + } + }, + ), + SliverToBoxAdapter( + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.isLoading != current.isLoading, + builder: (context, state) => state.isLoading + ? const LinearProgressIndicator() + : const SizedBox(height: 4), + ), + ), + _BlocBuilder( + buildWhen: (previous, current) => + previous.isEditMode != current.isEditMode, + builder: (context, state) { + if (!state.isEditMode) { + return const _ContentList(); + } else { + if (state.collection.capabilities + .contains(CollectionCapability.manualSort)) { + return const _EditContentList(); + } else { + return const SliverIgnorePointer( + ignoring: true, + sliver: SliverOpacity( + opacity: .25, + sliver: _ContentList(), + ), + ); + } + } + }, + ), + ], + ), + _BlocBuilder( + buildWhen: (previous, current) => + previous.isEditBusy != current.isEditBusy, + builder: (context, state) { + if (state.isEditBusy) { + return Container( + color: Colors.black.withOpacity(.5), + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ], + ), + ), + ), + ); + } + + late final _bloc = context.read<_Bloc>(); +} + +class _ContentList extends StatelessWidget { + const _ContentList(); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: context.read().albumBrowserZoomLevel, + initialData: context.read().albumBrowserZoomLevel.value, + builder: (_, zoomLevel) { + if (zoomLevel.hasError) { + context.read<_Bloc>().add( + _SetMessage(L10n.global().writePreferenceFailureNotification)); + } + return _BlocBuilder( + buildWhen: (previous, current) => + previous.transformedItems != current.transformedItems || + previous.selectedItems != current.selectedItems, + builder: (context, state) { + return SelectableItemList<_Item>( + maxCrossAxisExtent: photo_list_util + .getThumbSize(zoomLevel.requireData) + .toDouble(), + items: state.transformedItems, + itemBuilder: (context, _, item) => item.buildWidget(context), + staggeredTileBuilder: (_, item) => item.staggeredTile, + selectedItems: state.selectedItems, + onSelectionChange: (_, selected) { + context + .read<_Bloc>() + .add(_SetSelectedItems(items: selected.cast())); + }, + onItemTap: (context, index, _) { + final actualIndex = index - + state.transformedItems + .sublist(0, index) + .where((e) => e is! _ActualItem) + .length; + Navigator.of(context).pushNamed( + Viewer.routeName, + arguments: ViewerArguments( + context.read<_Bloc>().account, + state.transformedItems + .whereType<_FileItem>() + .map((e) => e.file) + .toList(), + actualIndex, + ), + ); + }, + ); + }, + ); + }, + ); + } +} + +class _EditContentList extends StatelessWidget { + const _EditContentList(); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: context.read().albumBrowserZoomLevel, + initialData: context.read().albumBrowserZoomLevel.value, + builder: (_, zoomLevel) { + if (zoomLevel.hasError) { + context.read<_Bloc>().add( + _SetMessage(L10n.global().writePreferenceFailureNotification)); + } + return _BlocBuilder( + buildWhen: (previous, current) => + previous.editTransformedItems != current.editTransformedItems, + builder: (context, state) { + if (state.collection.capabilities + .contains(CollectionCapability.manualSort)) { + return DraggableItemList<_Item>( + maxCrossAxisExtent: photo_list_util + .getThumbSize(zoomLevel.requireData) + .toDouble(), + items: state.editTransformedItems ?? state.transformedItems, + itemBuilder: (context, _, item) => item.buildWidget(context), + itemDragFeedbackBuilder: (context, _, item) => + item.buildDragFeedbackWidget(context), + staggeredTileBuilder: (_, item) => item.staggeredTile, + onDragResult: (results) { + context.read<_Bloc>().add(_EditManualSort(results)); + }, + ); + } else { + return SelectableItemList<_Item>( + maxCrossAxisExtent: photo_list_util + .getThumbSize(zoomLevel.requireData) + .toDouble(), + items: state.editTransformedItems ?? state.transformedItems, + itemBuilder: (context, _, item) => item.buildWidget(context), + staggeredTileBuilder: (_, item) => item.staggeredTile, + ); + } + }, + ); + }, + ); + } +} diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart new file mode 100644 index 00000000..4f64e2dc --- /dev/null +++ b/app/lib/widget/collection_browser.g.dart @@ -0,0 +1,265 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'collection_browser.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {Collection? collection, + String? coverUrl, + List? items, + bool? isLoading, + List<_Item>? transformedItems, + Set<_Item>? selectedItems, + bool? isSelectionRemovable, + bool? isSelectionManageableFile, + bool? isEditMode, + bool? isEditBusy, + String? editName, + List? editItems, + List<_Item>? editTransformedItems, + CollectionItemSort? editSort, + ExceptionEvent? error, + String? message}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic collection, + dynamic coverUrl = copyWithNull, + dynamic items, + dynamic isLoading, + dynamic transformedItems, + dynamic selectedItems, + dynamic isSelectionRemovable, + dynamic isSelectionManageableFile, + dynamic isEditMode, + dynamic isEditBusy, + dynamic editName = copyWithNull, + dynamic editItems = copyWithNull, + dynamic editTransformedItems = copyWithNull, + dynamic editSort = copyWithNull, + dynamic error = copyWithNull, + dynamic message = copyWithNull}) { + return _State( + collection: collection as Collection? ?? that.collection, + coverUrl: + coverUrl == copyWithNull ? that.coverUrl : coverUrl as String?, + items: items as List? ?? that.items, + isLoading: isLoading as bool? ?? that.isLoading, + transformedItems: + transformedItems as List<_Item>? ?? that.transformedItems, + selectedItems: selectedItems as Set<_Item>? ?? that.selectedItems, + isSelectionRemovable: + isSelectionRemovable as bool? ?? that.isSelectionRemovable, + isSelectionManageableFile: isSelectionManageableFile as bool? ?? + that.isSelectionManageableFile, + isEditMode: isEditMode as bool? ?? that.isEditMode, + isEditBusy: isEditBusy as bool? ?? that.isEditBusy, + editName: + editName == copyWithNull ? that.editName : editName as String?, + editItems: editItems == copyWithNull + ? that.editItems + : editItems as List?, + editTransformedItems: editTransformedItems == copyWithNull + ? that.editTransformedItems + : editTransformedItems as List<_Item>?, + editSort: editSort == copyWithNull + ? that.editSort + : editSort as CollectionItemSort?, + error: error == copyWithNull ? that.error : error as ExceptionEvent?, + message: message == copyWithNull ? that.message : message as String?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedCollectionBrowserStateNpLog + on _WrappedCollectionBrowserState { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.collection_browser._WrappedCollectionBrowserState"); +} + +extension _$_SelectionAppBarNpLog on _SelectionAppBar { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.collection_browser._SelectionAppBar"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.collection_browser._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {collection: $collection, coverUrl: $coverUrl, items: [length: ${items.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: $selectedItems, isSelectionRemovable: $isSelectionRemovable, isSelectionManageableFile: $isSelectionManageableFile, isEditMode: $isEditMode, isEditBusy: $isEditBusy, editName: $editName, editItems: ${editItems == null ? null : "[length: ${editItems!.length}]"}, editTransformedItems: ${editTransformedItems == null ? null : "[length: ${editTransformedItems!.length}]"}, editSort: ${editSort == null ? null : "${editSort!.name}"}, error: $error, message: $message}"; + } +} + +extension _$_UpdateCollectionToString on _UpdateCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_UpdateCollection {collection: $collection}"; + } +} + +extension _$_LoadItemsToString on _LoadItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadItems {}"; + } +} + +extension _$_TransformItemsToString on _TransformItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformItems {items: [length: ${items.length}]}"; + } +} + +extension _$_BeginEditToString on _BeginEdit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_BeginEdit {}"; + } +} + +extension _$_EditNameToString on _EditName { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_EditName {name: $name}"; + } +} + +extension _$_AddLabelToCollectionToString on _AddLabelToCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_AddLabelToCollection {label: $label}"; + } +} + +extension _$_EditSortToString on _EditSort { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_EditSort {sort: ${sort.name}}"; + } +} + +extension _$_EditManualSortToString on _EditManualSort { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_EditManualSort {sorted: [length: ${sorted.length}]}"; + } +} + +extension _$_TransformEditItemsToString on _TransformEditItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformEditItems {items: [length: ${items.length}]}"; + } +} + +extension _$_DoneEditToString on _DoneEdit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_DoneEdit {}"; + } +} + +extension _$_CancelEditToString on _CancelEdit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_CancelEdit {}"; + } +} + +extension _$_SetSelectedItemsToString on _SetSelectedItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetSelectedItems {items: $items}"; + } +} + +extension _$_DownloadSelectedItemsToString on _DownloadSelectedItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_DownloadSelectedItems {}"; + } +} + +extension _$_AddSelectedItemsToCollectionToString + on _AddSelectedItemsToCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_AddSelectedItemsToCollection {collection: $collection}"; + } +} + +extension _$_RemoveSelectedItemsFromCollectionToString + on _RemoveSelectedItemsFromCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RemoveSelectedItemsFromCollection {}"; + } +} + +extension _$_ArchiveSelectedItemsToString on _ArchiveSelectedItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ArchiveSelectedItems {}"; + } +} + +extension _$_DeleteSelectedItemsToString on _DeleteSelectedItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_DeleteSelectedItems {}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} + +extension _$_SetMessageToString on _SetMessage { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetMessage {message: $message}"; + } +} diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart new file mode 100644 index 00000000..3c41d86a --- /dev/null +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -0,0 +1,360 @@ +part of '../collection_browser.dart'; + +class _AppBar extends StatelessWidget { + const _AppBar(); + + @override + Widget build(BuildContext context) { + // capability can't be changed once the collection is created + final capabilities = context.read<_Bloc>().state.collection.capabilities; + return SliverAppBar( + floating: true, + expandedHeight: 160, + flexibleSpace: FlexibleSpaceBar( + background: const _AppBarCover(), + title: _BlocBuilder( + buildWhen: (previous, current) => + previous.collection.name != current.collection.name, + builder: (context, state) => Text( + state.collection.name, + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + ), + ), + ), + actions: [ + ZoomMenuButton( + initialZoom: 0, + minZoom: 0, + maxZoom: 2, + onZoomChanged: (value) { + context.read().setAlbumBrowserZoomLevel(value); + }, + ), + if (capabilities.contains(CollectionCapability.rename)) + PopupMenuButton<_MenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) { + return [ + if (capabilities.contains(CollectionCapability.rename)) + PopupMenuItem( + value: _MenuOption.edit, + child: Text(L10n.global().editTooltip), + ), + ]; + }, + onSelected: (option) { + _onMenuSelected(context, option); + }, + ), + ], + ); + } + + void _onMenuSelected(BuildContext context, _MenuOption option) { + switch (option) { + case _MenuOption.edit: + context.read<_Bloc>().add(const _BeginEdit()); + break; + } + } +} + +class _AppBarCover extends StatelessWidget { + const _AppBarCover(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => previous.coverUrl != current.coverUrl, + builder: (context, state) { + if (state.coverUrl != null) { + return Opacity( + opacity: + Theme.of(context).brightness == Brightness.light ? 0.25 : 0.35, + child: FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CachedNetworkImage( + cacheManager: CoverCacheManager.inst, + imageUrl: state.coverUrl!, + httpHeaders: { + "Authorization": + AuthUtil.fromAccount(context.read<_Bloc>().account) + .toHeaderValue(), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + errorWidget: (context, url, error) { + // just leave it empty + return Container(); + }, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + }, + ); + } +} + +@npLog +class _SelectionAppBar extends StatelessWidget { + const _SelectionAppBar(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.selectedItems != current.selectedItems, + builder: (context, state) => SelectionAppBar( + count: state.selectedItems.length, + onClosePressed: () { + context.read<_Bloc>().add(const _SetSelectedItems(items: {})); + }, + actions: [ + IconButton( + icon: const Icon(Icons.share_outlined), + tooltip: L10n.global().shareTooltip, + onPressed: () { + _onSelectionSharePressed(context); + }, + ), + IconButton( + icon: const Icon(Icons.add_outlined), + tooltip: L10n.global().addToAlbumTooltip, + onPressed: () { + _onSelectionAddPressed(context); + }, + ), + PopupMenuButton<_SelectionMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + if (state.collection.capabilities + .contains(CollectionCapability.manualItem) && + state.isSelectionRemovable) + PopupMenuItem( + value: _SelectionMenuOption.removeFromAlbum, + child: Text(L10n.global().removeFromAlbumTooltip), + ), + PopupMenuItem( + value: _SelectionMenuOption.download, + child: Text(L10n.global().downloadTooltip), + ), + if (state.isSelectionManageableFile) ...[ + PopupMenuItem( + value: _SelectionMenuOption.archive, + child: Text(L10n.global().archiveTooltip), + ), + PopupMenuItem( + value: _SelectionMenuOption.delete, + child: Text(L10n.global().deleteTooltip), + ), + ], + ], + onSelected: (option) { + _onSelectionMenuSelected(context, option); + }, + ), + ], + ), + ); + } + + void _onSelectionMenuSelected( + BuildContext context, _SelectionMenuOption option) { + switch (option) { + case _SelectionMenuOption.download: + context.read<_Bloc>().add(const _DownloadSelectedItems()); + break; + case _SelectionMenuOption.removeFromAlbum: + context.read<_Bloc>().add(const _RemoveSelectedItemsFromCollection()); + break; + case _SelectionMenuOption.archive: + context.read<_Bloc>().add(const _ArchiveSelectedItems()); + break; + case _SelectionMenuOption.delete: + context.read<_Bloc>().add(const _DeleteSelectedItems()); + break; + default: + _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); + break; + } + } + + Future _onSelectionSharePressed(BuildContext context) async { + final bloc = context.read<_Bloc>(); + final selected = bloc.state.selectedItems + .whereType<_FileItem>() + .map((e) => e.file) + .toList(); + if (selected.isEmpty) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().shareSelectedEmptyNotification), + duration: k.snackBarDurationNormal, + )); + return; + } + final result = await showDialog( + context: context, + builder: (context) => FileSharer( + account: bloc.account, + files: selected, + ), + ); + if (result ?? false) { + bloc.add(const _SetSelectedItems(items: {})); + } + } + + Future _onSelectionAddPressed(BuildContext context) async { + final collection = await Navigator.of(context) + .pushNamed(CollectionPicker.routeName); + if (collection == null) { + return; + } + context.read<_Bloc>().add(_AddSelectedItemsToCollection(collection)); + } +} + +class _EditAppBar extends StatelessWidget { + const _EditAppBar(); + + @override + Widget build(BuildContext context) { + final capabilities = context.read<_Bloc>().state.collection.capabilities; + return SliverAppBar( + floating: true, + expandedHeight: 160, + flexibleSpace: FlexibleSpaceBar( + background: const _AppBarCover(), + title: TextFormField( + initialValue: context.read<_Bloc>().state.currentEditName, + decoration: InputDecoration( + hintText: L10n.global().nameInputHint, + ), + validator: (_) { + // use text in state here because the value might be wrong if user + // scrolled the app bar off screen + if (context.read<_Bloc>().state.currentEditName.isNotEmpty) { + return null; + } else { + return L10n.global().albumNameInputInvalidEmpty; + } + }, + onChanged: (value) { + context.read<_Bloc>().add(_EditName(value)); + }, + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + ), + ), + leading: IconButton( + icon: const Icon(Icons.check), + color: Theme.of(context).colorScheme.primary, + tooltip: L10n.global().doneButtonTooltip, + onPressed: () { + context.read<_Bloc>().add(const _DoneEdit()); + }, + ), + actions: [ + if (capabilities.contains(CollectionCapability.labelItem)) + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: L10n.global().albumAddTextTooltip, + onPressed: () => _onAddTextPressed(context), + ), + if (capabilities.contains(CollectionCapability.sort)) + IconButton( + icon: const Icon(Icons.sort_by_alpha), + tooltip: L10n.global().sortTooltip, + onPressed: () => _onSortPressed(context), + ), + ], + ); + } + + Future _onAddTextPressed(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => SimpleInputDialog( + buttonText: MaterialLocalizations.of(context).saveButtonLabel, + ), + ); + if (result == null) { + return; + } + context.read<_Bloc>().add(_AddLabelToCollection(result)); + } + + Future _onSortPressed(BuildContext context) async { + final current = context + .read<_Bloc>() + .state + .run((s) => s.editSort ?? s.collection.itemSort); + final result = await showDialog( + context: context, + builder: (context) => FancyOptionPicker( + title: L10n.global().sortOptionDialogTitle, + items: [ + _SortDialogParams( + L10n.global().sortOptionTimeDescendingLabel, + CollectionItemSort.dateDescending, + ), + _SortDialogParams( + L10n.global().sortOptionTimeAscendingLabel, + CollectionItemSort.dateAscending, + ), + _SortDialogParams( + L10n.global().sortOptionFilenameAscendingLabel, + CollectionItemSort.nameAscending, + ), + _SortDialogParams( + L10n.global().sortOptionFilenameDescendingLabel, + CollectionItemSort.nameDescending, + ), + if (current == CollectionItemSort.manual) + _SortDialogParams( + L10n.global().sortOptionManualLabel, + CollectionItemSort.manual, + ), + ] + .map((e) => FancyOptionPickerItem( + label: e.label, + isSelected: current == e.value, + onSelect: () { + Navigator.of(context).pop(e.value); + }, + )) + .toList(), + ), + ); + if (result == null) { + return; + } + context.read<_Bloc>().add(_EditSort(result)); + } +} + +enum _MenuOption { + edit, +} + +enum _SelectionMenuOption { + download, + removeFromAlbum, + archive, + delete, +} + +class _SortDialogParams { + const _SortDialogParams(this.label, this.value); + + final String label; + final CollectionItemSort value; +} diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart new file mode 100644 index 00000000..3f0a15af --- /dev/null +++ b/app/lib/widget/collection_browser/bloc.dart @@ -0,0 +1,430 @@ +part of '../collection_browser.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> implements BlocTag { + _Bloc({ + required DiContainer container, + required this.account, + required this.collectionsController, + required Collection collection, + }) : _c = container, + super(_State.init( + collection: collection, + coverUrl: _getCoverUrl(collection), + )) { + _initItemController(collection); + + on<_UpdateCollection>(_onUpdateCollection); + on<_LoadItems>(_onLoad); + on<_TransformItems>(_onTransformItems); + + on<_BeginEdit>(_onBeginEdit); + on<_EditName>(_onEditName); + on<_AddLabelToCollection>(_onAddLabelToCollection); + on<_EditSort>(_onEditSort); + on<_EditManualSort>(_onEditManualSort); + on<_TransformEditItems>(_onTransformEditItems); + on<_DoneEdit>(_onDoneEdit, transformer: concurrent()); + on<_CancelEdit>(_onCancelEdit); + + on<_SetSelectedItems>(_onSetSelectedItems); + on<_DownloadSelectedItems>(_onDownloadSelectedItems); + on<_AddSelectedItemsToCollection>(_onAddSelectedItemsToCollection); + on<_RemoveSelectedItemsFromCollection>( + _onRemoveSelectedItemsFromCollection); + on<_ArchiveSelectedItems>(_onArchiveSelectedItems, + transformer: concurrent()); + on<_DeleteSelectedItems>(_onDeleteSelectedItems); + + on<_SetError>(_onSetError); + on<_SetMessage>(_onSetMessage); + + _collectionControllerSubscription = + collectionsController.stream.listen((event) { + final c = event.data + .firstWhere((d) => state.collection.compareIdentity(d.collection)); + if (!identical(c, state.collection)) { + add(_UpdateCollection(c.collection)); + } + }); + _itemsControllerSubscription = itemsController.stream.listen( + (_) {}, + onError: (e, stackTrace) { + add(_SetError(e, stackTrace)); + }, + ); + } + + @override + Future close() { + _collectionControllerSubscription?.cancel(); + _itemsControllerSubscription?.cancel(); + return super.close(); + } + + @override + String get tag => _log.fullName; + + void _onUpdateCollection(_UpdateCollection ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(collection: ev.collection)); + } + + Future _onLoad(_LoadItems ev, Emitter<_State> emit) { + _log.info("$ev"); + return emit.forEach( + itemsController.stream.handleError((e, stackTrace) { + _log.severe("[_onLoad] Uncaught exception", e, stackTrace); + return state.copyWith( + isLoading: false, + error: ExceptionEvent(e, stackTrace), + ); + }), + onData: (data) => state.copyWith( + items: data.items, + isLoading: data.hasNext, + ), + ); + } + + void _onTransformItems(_TransformItems ev, Emitter<_State> emit) { + _log.info("$ev"); + final result = _transformItems(ev.items, state.collection.itemSort); + var newState = state.copyWith(transformedItems: result.transformed); + if (state.coverUrl == null) { + // if cover is not managed by the collection, use the first item + final cover = _getCoverUrlOnNewItem(result.sorted); + if (cover != null) { + newState = newState.copyWith(coverUrl: cover); + } + } + emit(newState); + } + + void _onBeginEdit(_BeginEdit ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(isEditMode: true)); + } + + void _onEditName(_EditName ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(editName: ev.name)); + } + + void _onAddLabelToCollection(_AddLabelToCollection ev, Emitter<_State> emit) { + _log.info("$ev"); + assert( + state.collection.capabilities.contains(CollectionCapability.labelItem)); + emit(state.copyWith( + editItems: [ + NewCollectionLabelItem(ev.label, clock.now().toUtc()), + ...state.editItems ?? state.items, + ], + )); + } + + void _onEditSort(_EditSort ev, Emitter<_State> emit) { + _log.info("$ev"); + final result = _transformItems(state.editItems ?? state.items, ev.sort); + emit(state.copyWith( + editSort: ev.sort, + editTransformedItems: result.transformed, + )); + } + + void _onEditManualSort(_EditManualSort ev, Emitter<_State> emit) { + _log.info("$ev"); + assert(state.collection.capabilities + .contains(CollectionCapability.manualSort)); + emit(state.copyWith( + editSort: CollectionItemSort.manual, + editItems: + ev.sorted.whereType<_ActualItem>().map((e) => e.original).toList(), + editTransformedItems: ev.sorted, + )); + } + + void _onTransformEditItems(_TransformEditItems ev, Emitter<_State> emit) { + _log.info("$ev"); + final result = + _transformItems(ev.items, state.editSort ?? state.collection.itemSort); + emit(state.copyWith(editTransformedItems: result.transformed)); + } + + Future _onDoneEdit(_DoneEdit ev, Emitter<_State> emit) async { + _log.info("$ev"); + emit(state.copyWith(isEditBusy: true)); + try { + await collectionsController.edit( + state.collection, + name: state.editName, + items: state.editItems, + itemSort: state.editSort, + ); + emit(state.copyWith( + isEditMode: false, + isEditBusy: false, + editName: null, + editItems: null, + editTransformedItems: null, + editSort: null, + )); + } catch (e, stackTrace) { + _log.severe("[_onDoneEdit] Failed while edit", e, stackTrace); + emit(state.copyWith( + error: ExceptionEvent(e, stackTrace), + isEditBusy: false, + )); + } + } + + void _onCancelEdit(_CancelEdit ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith( + isEditMode: false, + editName: null, + editItems: null, + editTransformedItems: null, + editSort: null, + )); + } + + void _onSetSelectedItems(_SetSelectedItems ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith( + selectedItems: ev.items, + isSelectionRemovable: CollectionAdapter.of(_c, account, state.collection) + .isItemsRemovable(ev.items + .whereType<_ActualItem>() + .map((e) => e.original) + .toList()), + )); + } + + void _onDownloadSelectedItems( + _DownloadSelectedItems ev, Emitter<_State> emit) { + _log.info("$ev"); + final selected = state.selectedItems; + _clearSelection(emit); + final selectedFiles = + selected.whereType<_FileItem>().map((e) => e.file).toList(); + if (selectedFiles.isNotEmpty) { + unawaited(DownloadHandler(_c).downloadFiles(account, selectedFiles)); + } + } + + void _onAddSelectedItemsToCollection( + _AddSelectedItemsToCollection ev, Emitter<_State> emit) { + _log.info("$ev"); + final selected = state.selectedItems; + _clearSelection(emit); + final selectedFiles = + selected.whereType<_FileItem>().map((e) => e.file).toList(); + if (selectedFiles.isNotEmpty) { + final targetController = collectionsController.stream.value + .itemsControllerByCollection(ev.collection); + unawaited(targetController.addFiles(selectedFiles)); + } + } + + void _onRemoveSelectedItemsFromCollection( + _RemoveSelectedItemsFromCollection ev, Emitter<_State> emit) { + _log.info("$ev"); + final selected = state.selectedItems; + _clearSelection(emit); + final selectedItems = + selected.whereType<_ActualItem>().map((e) => e.original).toList(); + if (selectedItems.isNotEmpty) { + unawaited(itemsController.removeItems(selectedItems)); + } + } + + Future _onArchiveSelectedItems( + _ArchiveSelectedItems ev, Emitter<_State> emit) async { + _log.info("$ev"); + final selected = state.selectedItems; + _clearSelection(emit); + final selectedFds = + selected.whereType<_FileItem>().map((e) => e.file).toList(); + if (selectedFds.isNotEmpty) { + final selectedFiles = + await InflateFileDescriptor(_c)(account, selectedFds); + final count = await ArchiveFile(_c)(account, selectedFiles); + if (count != selectedFiles.length) { + emit(state.copyWith( + message: L10n.global() + .archiveSelectedFailureNotification(selectedFiles.length - count), + )); + } + } + } + + Future _onDeleteSelectedItems( + _DeleteSelectedItems ev, Emitter<_State> emit) async { + _log.info("$ev"); + final selected = state.selectedItems; + _clearSelection(emit); + final selectedFiles = + selected.whereType<_FileItem>().map((e) => e.file).toList(); + if (selectedFiles.isNotEmpty) { + final count = await Remove(_c)( + account, + selectedFiles, + onError: (_, f, e, stackTrace) { + _log.severe( + "[_onDeleteSelectedItems] Failed while Remove: ${logFilename(f.strippedPath)}", + e, + stackTrace, + ); + }, + ); + if (count != selectedFiles.length) { + emit(state.copyWith( + message: L10n.global() + .deleteSelectedFailureNotification(selectedFiles.length - count), + )); + } + } + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + void _onSetMessage(_SetMessage ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(message: ev.message)); + } + + void _initItemController(Collection collection) { + try { + itemsController = collectionsController.stream.value + .itemsControllerByCollection(collection); + } catch (e) { + _log.info( + "[_initItemController] Collection not found in global controller, building new ad-hoc item controller", + e, + ); + itemsController = CollectionItemsController( + _c, + account: account, + collection: collection, + onCollectionUpdated: (_) {}, + ); + } + } + + void _clearSelection(Emitter<_State> emit) { + emit(state.copyWith( + selectedItems: const {}, + isSelectionRemovable: true, + isSelectionManageableFile: true, + )); + } + + /// Convert [CollectionItem] to the corresponding [_Item] + _TransformResult _transformItems( + List items, CollectionItemSort sort) { + final sorter = CollectionSorter.fromSortType(sort); + final sortedItems = sorter(items); + final dateHelper = photo_list_util.DateGroupHelper(isMonthOnly: false); + + final transformed = <_Item>[]; + for (int i = 0; i < sortedItems.length; ++i) { + final item = sortedItems[i]; + if (item is CollectionFileItem) { + if (sorter is CollectionTimeSorter && + _c.pref.isAlbumBrowserShowDateOr()) { + final date = dateHelper.onFile(item.file); + if (date != null) { + transformed.add(_DateItem(date: date)); + } + } + + if (file_util.isSupportedImageMime(item.file.fdMime ?? "")) { + transformed.add(_PhotoItem( + original: item, + file: item.file, + account: account, + )); + } else if (file_util.isSupportedVideoMime(item.file.fdMime ?? "")) { + transformed.add(_VideoItem( + original: item, + file: item.file, + account: account, + )); + } else { + _log.shout( + "[_transformItems] Unsupported file format: ${item.file.fdMime}"); + } + } else if (item is CollectionLabelItem) { + if (state.isEditMode) { + transformed.add(_EditLabelListItem( + original: item, + id: item.id, + text: item.text, + onEditPressed: () { + // TODO + }, + )); + } else { + transformed.add(_LabelItem( + original: item, + id: item.id, + text: item.text, + )); + } + } + } + return _TransformResult( + sorted: sortedItems, + transformed: transformed, + ); + } + + String? _getCoverUrlOnNewItem(List sortedItems) { + try { + final firstFile = + (sortedItems.firstWhereOrNull((i) => i is CollectionFileItem) + as CollectionFileItem?) + ?.file; + if (firstFile != null) { + return api_util.getFilePreviewUrlByFileId( + account, + firstFile.fdId, + width: k.coverSize, + height: k.coverSize, + isKeepAspectRatio: false, + ); + } + } catch (_) {} + return null; + } + + static String? _getCoverUrl(Collection collection) { + try { + return collection.contentProvider.getCoverUrl(k.coverSize, k.coverSize); + } catch (_) { + return null; + } + } + + final DiContainer _c; + final Account account; + final CollectionsController collectionsController; + late final CollectionItemsController itemsController; + + StreamSubscription? _collectionControllerSubscription; + StreamSubscription? _itemsControllerSubscription; +} + +class _TransformResult { + const _TransformResult({ + required this.sorted, + required this.transformed, + }); + + final List sorted; + final List<_Item> transformed; +} diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart new file mode 100644 index 00000000..6a544504 --- /dev/null +++ b/app/lib/widget/collection_browser/state_event.dart @@ -0,0 +1,257 @@ +part of '../collection_browser.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.collection, + this.coverUrl, + required this.items, + required this.isLoading, + required this.transformedItems, + required this.selectedItems, + required this.isSelectionRemovable, + required this.isSelectionManageableFile, + required this.isEditMode, + required this.isEditBusy, + this.editName, + this.editItems, + this.editTransformedItems, + this.editSort, + this.error, + this.message, + }); + + factory _State.init({ + required Collection collection, + required String? coverUrl, + }) { + return _State( + collection: collection, + coverUrl: coverUrl, + items: const [], + isLoading: false, + transformedItems: const [], + selectedItems: const {}, + isSelectionRemovable: true, + isSelectionManageableFile: true, + isEditMode: false, + isEditBusy: false, + ); + } + + @override + String toString() => _$toString(); + + String get currentEditName => editName ?? collection.name; + + final Collection collection; + final String? coverUrl; + final List items; + final bool isLoading; + final List<_Item> transformedItems; + + final Set<_Item> selectedItems; + final bool isSelectionRemovable; + final bool isSelectionManageableFile; + + final bool isEditMode; + final bool isEditBusy; + final String? editName; + final List? editItems; + final List<_Item>? editTransformedItems; + final CollectionItemSort? editSort; + + final ExceptionEvent? error; + final String? message; +} + +abstract class _Event {} + +@toString +class _UpdateCollection implements _Event { + const _UpdateCollection(this.collection); + + @override + String toString() => _$toString(); + + final Collection collection; +} + +/// Load the content of this collection +@toString +class _LoadItems implements _Event { + const _LoadItems(); + + @override + String toString() => _$toString(); +} + +/// Transform the collection list (e.g., filtering, sorting, etc) +@toString +class _TransformItems implements _Event { + const _TransformItems({ + required this.items, + }); + + @override + String toString() => _$toString(); + + final List items; +} + +@toString +class _BeginEdit implements _Event { + const _BeginEdit(); + + @override + String toString() => _$toString(); +} + +@toString +class _EditName implements _Event { + const _EditName(this.name); + + @override + String toString() => _$toString(); + + final String name; +} + +@toString +class _AddLabelToCollection implements _Event { + const _AddLabelToCollection(this.label); + + @override + String toString() => _$toString(); + + final String label; +} + +@toString +class _EditSort implements _Event { + const _EditSort(this.sort); + + @override + String toString() => _$toString(); + + final CollectionItemSort sort; +} + +@toString +class _EditManualSort implements _Event { + const _EditManualSort(this.sorted); + + @override + String toString() => _$toString(); + + final List<_Item> sorted; +} + +@toString +class _TransformEditItems implements _Event { + const _TransformEditItems({ + required this.items, + }); + + @override + String toString() => _$toString(); + + final List items; +} + +@toString +class _DoneEdit implements _Event { + const _DoneEdit(); + + @override + String toString() => _$toString(); +} + +@toString +class _CancelEdit implements _Event { + const _CancelEdit(); + + @override + String toString() => _$toString(); +} + +/// Set the currently selected items +@toString +class _SetSelectedItems implements _Event { + const _SetSelectedItems({ + required this.items, + }); + + @override + String toString() => _$toString(); + + final Set<_Item> items; +} + +/// Download the currently selected items +@toString +class _DownloadSelectedItems implements _Event { + const _DownloadSelectedItems(); + + @override + String toString() => _$toString(); +} + +@toString +class _AddSelectedItemsToCollection implements _Event { + const _AddSelectedItemsToCollection(this.collection); + + @override + String toString() => _$toString(); + + final Collection collection; +} + +/// Remove the currently selected items from this collection +@toString +class _RemoveSelectedItemsFromCollection implements _Event { + const _RemoveSelectedItemsFromCollection(); + + @override + String toString() => _$toString(); +} + +/// Archive the currently selected files +@toString +class _ArchiveSelectedItems implements _Event { + const _ArchiveSelectedItems(); + + @override + String toString() => _$toString(); +} + +/// Delete the currently selected files +@toString +class _DeleteSelectedItems implements _Event { + const _DeleteSelectedItems(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} + +@toString +class _SetMessage implements _Event { + const _SetMessage(this.message); + + @override + String toString() => _$toString(); + + final String message; +} diff --git a/app/lib/widget/collection_browser/type.dart b/app/lib/widget/collection_browser/type.dart new file mode 100644 index 00000000..0706dd2d --- /dev/null +++ b/app/lib/widget/collection_browser/type.dart @@ -0,0 +1,176 @@ +part of '../collection_browser.dart'; + +abstract class _Item implements SelectableItemMetadata, DraggableItemMetadata { + const _Item(); + + StaggeredTile get staggeredTile; + + Widget buildWidget(BuildContext context); + + Widget? buildDragFeedbackWidget(BuildContext context) => null; +} + +/// Items backed by an actual [CollectionItem] +abstract class _ActualItem extends _Item { + const _ActualItem({required this.original}); + + @override + bool get isSelectable => !_isNew; + + @override + bool get isDraggable => !_isNew; + + bool get _isNew => original is NewCollectionItem; + + final CollectionItem original; +} + +abstract class _FileItem extends _ActualItem { + const _FileItem({ + required super.original, + required this.file, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _FileItem && file.compareServerIdentity(other.file)); + + @override + int get hashCode => file.identityHashCode; + + final FileDescriptor file; +} + +class _PhotoItem extends _FileItem { + _PhotoItem({ + required super.original, + required super.file, + required this.account, + }) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file); + + @override + StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); + + @override + Widget buildWidget(BuildContext context) { + return Opacity( + opacity: _isNew ? .5 : 1, + child: PhotoListImage( + account: account, + previewUrl: _previewUrl, + isGif: file.fdMime == "image/gif", + isFavorite: file.fdIsFavorite, + heroKey: flutter_util.getImageHeroTag(file), + ), + ); + } + + final Account account; + final String _previewUrl; +} + +class _VideoItem extends _FileItem { + _VideoItem({ + required super.original, + required super.file, + required this.account, + }) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file); + + @override + StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); + + @override + Widget buildWidget(BuildContext context) { + return Opacity( + opacity: _isNew ? .5 : 1, + child: PhotoListVideo( + account: account, + previewUrl: _previewUrl, + isFavorite: file.fdIsFavorite, + ), + ); + } + + final Account account; + final String _previewUrl; +} + +class _LabelItem extends _ActualItem { + const _LabelItem({ + required super.original, + required this.id, + required this.text, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || (other is _LabelItem && id == other.id); + + @override + int get hashCode => id.hashCode; + + @override + StaggeredTile get staggeredTile => const StaggeredTile.extent(99, 56); + + @override + Widget buildWidget(BuildContext context) { + return PhotoListLabel( + text: text, + ); + } + + final Object id; + final String text; +} + +class _EditLabelListItem extends _LabelItem { + const _EditLabelListItem({ + required super.original, + required super.id, + required super.text, + required this.onEditPressed, + }); + + @override + bool get isDraggable => true; + + @override + Widget buildWidget(BuildContext context) { + return PhotoListLabelEdit( + text: text, + onEditPressed: onEditPressed, + ); + } + + @override + Widget? buildDragFeedbackWidget(BuildContext context) { + return super.buildWidget(context); + } + + final VoidCallback? onEditPressed; +} + +class _DateItem extends _Item { + const _DateItem({ + required this.date, + }); + + @override + bool get isSelectable => false; + + @override + bool get isDraggable => false; + + @override + StaggeredTile get staggeredTile => const StaggeredTile.extent(99, 32); + + @override + Widget buildWidget(BuildContext context) { + return PhotoListDate( + date: date, + ); + } + + final DateTime date; +} diff --git a/app/lib/widget/album_grid_item.dart b/app/lib/widget/collection_grid_item.dart similarity index 82% rename from app/lib/widget/album_grid_item.dart rename to app/lib/widget/collection_grid_item.dart index 53cb0cc9..12713496 100644 --- a/app/lib/widget/album_grid_item.dart +++ b/app/lib/widget/collection_grid_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -class AlbumGridItem extends StatelessWidget { - const AlbumGridItem({ +class CollectionGridItem extends StatelessWidget { + const CollectionGridItem({ Key? key, required this.cover, required this.title, @@ -11,7 +11,7 @@ class AlbumGridItem extends StatelessWidget { }) : super(key: key); @override - build(BuildContext context) { + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8), child: Column( @@ -26,10 +26,12 @@ class AlbumGridItem extends StatelessWidget { padding: const EdgeInsets.all(8), child: Align( alignment: AlignmentDirectional.topEnd, - child: Icon( - icon, - size: 16, - color: Colors.white, + child: IconTheme( + data: const IconThemeData( + size: 20, + color: Colors.white, + ), + child: icon!, ), ), ), @@ -64,7 +66,7 @@ class AlbumGridItem extends StatelessWidget { maxLines: 1, ), ], - ) + ), ], ), ); @@ -76,5 +78,5 @@ class AlbumGridItem extends StatelessWidget { /// Appears after [subtitle], aligned to the end side of parent final String? subtitle2; - final IconData? icon; + final Widget? icon; } diff --git a/app/lib/widget/collection_picker.dart b/app/lib/widget/collection_picker.dart new file mode 100644 index 00000000..98ea9ceb --- /dev/null +++ b/app/lib/widget/collection_picker.dart @@ -0,0 +1,289 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart' as collection_util; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/np_api_util.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/app_bar_circular_progress_indicator.dart'; +import 'package:nc_photos/widget/collection_grid_item.dart'; +import 'package:nc_photos/widget/new_collection_dialog.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'collection_picker.g.dart'; +part 'collection_picker/bloc.dart'; +part 'collection_picker/state_event.dart'; +part 'collection_picker/type.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; + +/// Show a list of [Collection]s and return the one picked by the user +class CollectionPicker extends StatelessWidget { + static const routeName = "/collection-picker"; + + static Route buildRoute() => MaterialPageRoute( + builder: (context) => const CollectionPicker(), + ); + + const CollectionPicker({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + account: context.read().account, + controller: context.read().collectionsController, + ), + child: const _WrappedCollectionPicker(), + ); + } +} + +class _WrappedCollectionPicker extends StatefulWidget { + const _WrappedCollectionPicker(); + + @override + State createState() => _WrappedCollectionPickerState(); +} + +@npLog +class _WrappedCollectionPickerState extends State<_WrappedCollectionPicker> { + @override + void initState() { + super.initState(); + _bloc.add(const _LoadCollections()); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.collections != current.collections, + listener: (context, state) { + _bloc.add(_TransformItems(state.collections)); + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.result != current.result, + listener: (context, state) { + if (state.result != null) { + Navigator.of(context).pop(state.result!); + } + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.error != current.error, + listener: (context, state) { + if (state.error != null) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: CustomScrollView( + slivers: [ + const _AppBar(), + _BlocBuilder( + buildWhen: (previous, current) => + previous.transformedItems != current.transformedItems, + builder: (context, state) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: SliverStaggeredGrid.extentBuilder( + maxCrossAxisExtent: 256, + staggeredTileBuilder: (_) => const StaggeredTile.count(1, 1), + itemCount: state.transformedItems.length + 1, + itemBuilder: (_, index) { + if (index == 0) { + return _NewAlbumView(); + } else { + final item = state.transformedItems[index - 1]; + return _ItemView( + account: _bloc.account, + item: item, + ); + } + }, + ), + ), + ), + ], + ), + ); + } + + late final _Bloc _bloc = context.read(); +} + +class _AppBar extends StatelessWidget { + const _AppBar(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => previous.isLoading != current.isLoading, + builder: (context, state) => SliverAppBar( + title: Text(L10n.global().addToAlbumTooltip), + floating: true, + leading: + state.isLoading ? const AppBarCircularProgressIndicator() : null, + ), + ); + } +} + +class _ItemView extends StatelessWidget { + const _ItemView({ + required this.account, + required this.item, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + CollectionGridItem( + cover: _CollectionCover( + account: account, + url: item.coverUrl, + ), + title: item.name, + ), + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + context.read<_Bloc>().add(_SelectCollection(item.collection)); + }, + ), + ), + ), + ], + ); + } + + final Account account; + final _Item item; +} + +class _CollectionCover extends StatelessWidget { + const _CollectionCover({ + required this.account, + required this.url, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Theme.of(context).listPlaceholderBackgroundColor, + constraints: const BoxConstraints.expand(), + child: url != null + ? FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CachedNetworkImage( + cacheManager: CoverCacheManager.inst, + imageUrl: url!, + httpHeaders: { + "Authorization": + AuthUtil.fromAccount(account).toHeaderValue(), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + errorWidget: (context, url, error) { + // just leave it empty + return Container(); + }, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + ), + ) + : Icon( + Icons.panorama, + color: Theme.of(context).listPlaceholderForegroundColor, + size: 88, + ), + ), + ); + } + + final Account account; + final String? url; +} + +@npLog +class _NewAlbumView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Stack( + children: [ + CollectionGridItem( + cover: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Theme.of(context).listPlaceholderBackgroundColor, + constraints: const BoxConstraints.expand(), + child: Icon( + Icons.add, + color: Theme.of(context).listPlaceholderForegroundColor, + size: 88, + ), + ), + ), + title: L10n.global().createAlbumTooltip, + ), + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => _onNewPressed(context), + ), + ), + ), + ], + ); + } + + Future _onNewPressed(BuildContext context) async { + try { + final collection = await showDialog( + context: context, + builder: (_) => NewCollectionDialog( + account: context.read<_Bloc>().account, + isAllowDynamic: false, + ), + ); + if (collection == null) { + // user canceled + return; + } + context.read<_Bloc>().add(_SelectCollection(collection)); + } catch (e, stackTrace) { + _log.shout("[_onNewPressed] Failed while showDialog", e, stackTrace); + context.read<_Bloc>().add(_SetError(e, stackTrace)); + } + } +} diff --git a/app/lib/widget/collection_picker.g.dart b/app/lib/widget/collection_picker.g.dart new file mode 100644 index 00000000..f6762ce2 --- /dev/null +++ b/app/lib/widget/collection_picker.g.dart @@ -0,0 +1,122 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'collection_picker.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List? collections, + bool? isLoading, + List<_Item>? transformedItems, + Collection? result, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic collections, + dynamic isLoading, + dynamic transformedItems, + dynamic result = copyWithNull, + dynamic error = copyWithNull}) { + return _State( + collections: collections as List? ?? that.collections, + isLoading: isLoading as bool? ?? that.isLoading, + transformedItems: + transformedItems as List<_Item>? ?? that.transformedItems, + result: result == copyWithNull ? that.result : result as Collection?, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedCollectionPickerStateNpLog + on _WrappedCollectionPickerState { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.collection_picker._WrappedCollectionPickerState"); +} + +extension _$_NewAlbumViewNpLog on _NewAlbumView { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.collection_picker._NewAlbumView"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.collection_picker._Bloc"); +} + +extension _$_ItemNpLog on _Item { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.collection_picker._Item"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {collections: [length: ${collections.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], result: $result, error: $error}"; + } +} + +extension _$_LoadCollectionsToString on _LoadCollections { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadCollections {}"; + } +} + +extension _$_TransformItemsToString on _TransformItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformItems {collections: [length: ${collections.length}]}"; + } +} + +extension _$_SelectCollectionToString on _SelectCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SelectCollection {collection: $collection}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/collection_picker/bloc.dart b/app/lib/widget/collection_picker/bloc.dart new file mode 100644 index 00000000..9055c2ba --- /dev/null +++ b/app/lib/widget/collection_picker/bloc.dart @@ -0,0 +1,60 @@ +part of '../collection_picker.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> implements BlocTag { + _Bloc({ + required this.account, + required this.controller, + }) : super(_State.init()) { + on<_LoadCollections>(_onLoad); + on<_TransformItems>(_onTransformItems); + on<_SelectCollection>(_onSelectCollection); + on<_SetError>(_onSetError); + } + + @override + String get tag => _log.fullName; + + Future _onLoad(_LoadCollections ev, Emitter<_State> emit) async { + _log.info("[_onLoad] $ev"); + return emit.forEach( + controller.stream, + onData: (data) => state.copyWith( + collections: data.data.map((e) => e.collection).toList(), + isLoading: data.hasNext, + ), + onError: (e, stackTrace) { + _log.severe("[_onLoad] Uncaught exception", e, stackTrace); + return state.copyWith( + isLoading: false, + error: ExceptionEvent(e, stackTrace), + ); + }, + ); + } + + void _onTransformItems(_TransformItems ev, Emitter<_State> emit) { + _log.info("[_onTransformItems] $ev"); + final transformed = _transformCollections(ev.collections); + emit(state.copyWith(transformedItems: transformed)); + } + + void _onSelectCollection(_SelectCollection ev, Emitter<_State> emit) { + _log.info("[_onTransformItems] $ev"); + emit(state.copyWith(result: ev.collection)); + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info("[_onSetError] $ev"); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + List<_Item> _transformCollections(List collections) { + final sorted = + collections.sortedBy(collection_util.CollectionSort.dateDescending); + return sorted.map((c) => _Item(c)).toList(); + } + + final Account account; + final CollectionsController controller; +} diff --git a/app/lib/widget/collection_picker/state_event.dart b/app/lib/widget/collection_picker/state_event.dart new file mode 100644 index 00000000..67c8149f --- /dev/null +++ b/app/lib/widget/collection_picker/state_event.dart @@ -0,0 +1,77 @@ +part of '../collection_picker.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.collections, + required this.isLoading, + required this.transformedItems, + this.result, + this.error, + }); + + factory _State.init() { + return const _State( + collections: [], + isLoading: false, + transformedItems: [], + ); + } + + @override + String toString() => _$toString(); + + final List collections; + final bool isLoading; + final List<_Item> transformedItems; + final Collection? result; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +/// Load the list of collections belonging to this account +@toString +class _LoadCollections implements _Event { + const _LoadCollections(); + + @override + String toString() => _$toString(); +} + +/// Transform the collection list (e.g., filtering, sorting, etc) +@toString +class _TransformItems implements _Event { + const _TransformItems(this.collections); + + @override + String toString() => _$toString(); + + final List collections; +} + +/// Select a collection +@toString +class _SelectCollection implements _Event { + const _SelectCollection(this.collection); + + @override + String toString() => _$toString(); + + final Collection collection; +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/app/lib/widget/collection_picker/type.dart b/app/lib/widget/collection_picker/type.dart new file mode 100644 index 00000000..a8ad533c --- /dev/null +++ b/app/lib/widget/collection_picker/type.dart @@ -0,0 +1,19 @@ +part of '../collection_picker.dart'; + +@npLog +class _Item { + _Item(this.collection) { + try { + _coverUrl = collection.getCoverUrl(k.coverSize, k.coverSize); + } catch (e, stackTrace) { + _log.warning("[_CollectionItem] Failed while getCoverUrl", e, stackTrace); + } + } + + String get name => collection.name; + + String? get coverUrl => _coverUrl; + + final Collection collection; + String? _coverUrl; +} diff --git a/app/lib/widget/draggable_item_list.dart b/app/lib/widget/draggable_item_list.dart new file mode 100644 index 00000000..74d903ee --- /dev/null +++ b/app/lib/widget/draggable_item_list.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/widget/draggable.dart' as my; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'draggable_item_list.g.dart'; + +/// Describe an item in a [DraggableItemList] +/// +/// Derived classes should implement [operator ==] in order for the list to +/// correctly map the items after changing the list content +abstract class DraggableItemMetadata { + bool get isDraggable; +} + +/// A list where some/all of the items can be dragged to rearrange them +class DraggableItemList + extends StatefulWidget { + const DraggableItemList({ + super.key, + required this.items, + required this.maxCrossAxisExtent, + required this.itemBuilder, + required this.itemDragFeedbackBuilder, + required this.staggeredTileBuilder, + this.onDragResult, + }); + + @override + State createState() => _DraggableItemListState(); + + final List items; + final double maxCrossAxisExtent; + final Widget Function(BuildContext context, int index, T metadata) + itemBuilder; + final Widget? Function(BuildContext context, int index, T metadata)? + itemDragFeedbackBuilder; + final StaggeredTile? Function(int index, T metadata) staggeredTileBuilder; + + /// Called when an item is dropped to a new place + /// + /// [results] contains the rearranged items + final void Function(List results)? onDragResult; +} + +@npLog +class _DraggableItemListState + extends State> { + @override + Widget build(BuildContext context) { + return SliverStaggeredGrid.extentBuilder( + key: ObjectKey(widget.maxCrossAxisExtent), + maxCrossAxisExtent: widget.maxCrossAxisExtent, + itemCount: widget.items.length, + itemBuilder: (context, i) { + final meta = widget.items[i]; + if (meta.isDraggable) { + return my.Draggable<_DraggableData>( + data: _DraggableData(i, meta), + feedback: widget.itemDragFeedbackBuilder?.call(context, i, meta), + onDropBefore: (data) => _onMoved(data.index, i, true), + onDropAfter: (data) => _onMoved(data.index, i, false), + feedbackSize: Size(widget.maxCrossAxisExtent * .65, + widget.maxCrossAxisExtent * .65), + child: widget.itemBuilder(context, i, meta), + ); + } else { + return widget.itemBuilder(context, i, meta); + } + }, + staggeredTileBuilder: (i) => + widget.staggeredTileBuilder(i, widget.items[i]), + ); + } + + void _onMoved(int fromIndex, int toIndex, bool isBefore) { + if (fromIndex == toIndex) { + return; + } + final newItems = widget.items.toList(); + final moved = newItems.removeAt(fromIndex); + final newIndex = + toIndex + (isBefore ? 0 : 1) + (fromIndex < toIndex ? -1 : 0); + newItems.insert(newIndex, moved); + widget.onDragResult?.call(newItems); + } +} + +@toString +class _DraggableData { + const _DraggableData(this.index, this.meta); + + @override + String toString() => _$toString(); + + final int index; + final DraggableItemMetadata meta; +} diff --git a/app/lib/widget/draggable_item_list.g.dart b/app/lib/widget/draggable_item_list.g.dart new file mode 100644 index 00000000..63f9bf4b --- /dev/null +++ b/app/lib/widget/draggable_item_list.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'draggable_item_list.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_DraggableItemListStateNpLog on _DraggableItemListState { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.draggable_item_list._DraggableItemListState"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_DraggableDataToString on _DraggableData { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_DraggableData {index: $index, meta: $meta}"; + } +} diff --git a/app/lib/widget/dynamic_album_browser.dart b/app/lib/widget/dynamic_album_browser.dart index 1e088d55..4680afbf 100644 --- a/app/lib/widget/dynamic_album_browser.dart +++ b/app/lib/widget/dynamic_album_browser.dart @@ -446,12 +446,13 @@ class _DynamicAlbumBrowserState extends State await Remove(KiwiContainer().resolve())( widget.account, selected.map((e) => e.file).toList(), - onRemoveFileFailed: (file, e, stackTrace) { + onError: (_, f, e, stackTrace) { _log.shout( - "[_onSelectionDeletePressed] Failed while removing file: ${logFilename(file.path)}", - e, - stackTrace); - successes.removeWhere((item) => item.file.compareServerIdentity(file)); + "[_onSelectionDeletePressed] Failed while removing file: ${logFilename(f.fdPath)}", + e, + stackTrace, + ); + successes.removeWhere((item) => item.file.compareServerIdentity(f)); }, ); diff --git a/app/lib/widget/file_sharer.dart b/app/lib/widget/file_sharer.dart new file mode 100644 index 00000000..37c1ae68 --- /dev/null +++ b/app/lib/widget/file_sharer.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:clock/clock.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/share.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/use_case/copy.dart'; +import 'package:nc_photos/use_case/create_dir.dart'; +import 'package:nc_photos/use_case/create_share.dart'; +import 'package:nc_photos/use_case/download_file.dart'; +import 'package:nc_photos/use_case/download_preview.dart'; +import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; +import 'package:nc_photos/widget/processing_dialog.dart'; +import 'package:nc_photos/widget/share_link_multiple_files_dialog.dart'; +import 'package:nc_photos/widget/simple_input_dialog.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; +import 'package:tuple/tuple.dart'; + +part 'file_sharer.g.dart'; +part 'file_sharer/bloc.dart'; +part 'file_sharer/state_event.dart'; +part 'file_sharer/type.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; + +/// Dialog to let user share files with different options +/// +/// Return true if the files are actually shared, false if user cancelled or +/// some errors occurred (e.g., missing permission) +class FileSharer extends StatelessWidget { + const FileSharer({ + super.key, + required this.account, + required this.files, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + container: KiwiContainer().resolve(), + account: account, + files: files, + ), + child: const _WrappedFileSharer(), + ); + } + + final Account account; + final List files; +} + +class _WrappedFileSharer extends StatelessWidget { + const _WrappedFileSharer(); + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.error != current.error, + listener: (context, state) { + if (state.error != null) { + if (state.error!.error is PermissionException) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().errorNoStoragePermission), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + } + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.message != current.message, + listener: (context, state) { + if (state.message != null) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(state.message!), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.result != current.result, + listener: (context, state) { + if (state.result != null) { + Navigator.of(context).pop(state.result); + } + }, + ), + ], + child: _BlocBuilder( + buildWhen: (previous, current) => previous.method != current.method, + builder: (context, state) { + switch (state.method) { + case null: + return const _ShareMethodDialog(); + case ShareMethod.file: + return const _ShareFileDialog(); + case ShareMethod.preview: + return const _SharePreviewDialog(); + case ShareMethod.publicLink: + return const _SharePublicLinkDialog(); + case ShareMethod.passwordLink: + return const _SharePasswordLinkDialog(); + } + }, + ), + ); + } +} + +class _ShareMethodDialog extends StatelessWidget { + const _ShareMethodDialog(); + + @override + Widget build(BuildContext context) { + final isSupportPerview = context + .read<_Bloc>() + .files + .any((f) => file_util.isSupportedImageFormat(f)); + return SimpleDialog( + title: Text(L10n.global().shareMethodDialogTitle), + children: [ + if (platform_k.isAndroid) ...[ + if (isSupportPerview) + SimpleDialogOption( + child: ListTile( + title: Text(L10n.global().shareMethodPreviewTitle), + subtitle: Text(L10n.global().shareMethodPreviewDescription), + ), + onPressed: () { + context + .read<_Bloc>() + .add(const _SetMethod(ShareMethod.preview)); + }, + ), + SimpleDialogOption( + child: ListTile( + title: Text(L10n.global().shareMethodOriginalFileTitle), + subtitle: Text(L10n.global().shareMethodOriginalFileDescription), + ), + onPressed: () { + context.read<_Bloc>().add(const _SetMethod(ShareMethod.file)); + }, + ), + ], + SimpleDialogOption( + child: ListTile( + title: Text(L10n.global().shareMethodPublicLinkTitle), + subtitle: Text(L10n.global().shareMethodPublicLinkDescription), + ), + onPressed: () { + context.read<_Bloc>().add(const _SetMethod(ShareMethod.publicLink)); + }, + ), + SimpleDialogOption( + child: ListTile( + title: Text(L10n.global().shareMethodPasswordLinkTitle), + subtitle: Text(L10n.global().shareMethodPasswordLinkDescription), + ), + onPressed: () { + context + .read<_Bloc>() + .add(const _SetMethod(ShareMethod.passwordLink)); + }, + ), + ], + ); + } +} + +class _ShareFileDialog extends StatelessWidget { + const _ShareFileDialog(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.previewState?.index != current.previewState?.index || + previous.previewState?.count != current.previewState?.count, + builder: (context, state) { + final text = state.previewState?.index != null && + state.previewState?.count != null + ? " (${state.previewState!.index}/${state.previewState!.count})" + : ""; + return ProcessingDialog( + text: L10n.global().shareDownloadingDialogContent + text, + ); + }, + ); + } +} + +class _SharePreviewDialog extends StatelessWidget { + const _SharePreviewDialog(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.previewState?.index != current.previewState?.index || + previous.previewState?.count != current.previewState?.count, + builder: (context, state) { + final text = state.previewState?.index != null && + state.previewState?.count != null + ? " (${state.previewState!.index}/${state.previewState!.count})" + : ""; + return ProcessingDialog( + text: L10n.global().shareDownloadingDialogContent + text, + ); + }, + ); + } +} + +class _SharePublicLinkDialog extends StatefulWidget { + const _SharePublicLinkDialog(); + + @override + State createState() => _SharePublicLinkDialogState(); +} + +class _SharePublicLinkDialogState extends State<_SharePublicLinkDialog> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _askDetails()); + } + + @override + Widget build(BuildContext context) { + return ProcessingDialog(text: L10n.global().createShareProgressText); + } + + Future _askDetails() async { + if (_bloc.files.length == 1) { + _bloc.add(const _SetPublicLinkDetails()); + } else { + final result = await showDialog( + context: context, + builder: (context) => const ShareLinkMultipleFilesDialog( + shouldAskPassword: false, + ), + ); + if (!mounted) { + return; + } + if (result == null) { + _bloc.add(const _SetResult(false)); + return; + } else { + _bloc.add(_SetPublicLinkDetails( + albumName: result.albumName, + )); + } + } + } + + late final _bloc = context.read<_Bloc>(); +} + +class _SharePasswordLinkDialog extends StatefulWidget { + const _SharePasswordLinkDialog(); + + @override + State createState() => _SharePasswordLinkDialogState(); +} + +class _SharePasswordLinkDialogState extends State<_SharePasswordLinkDialog> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _askDetails()); + } + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.passwordLinkState?.password != + current.passwordLinkState?.password, + builder: (context, state) { + if (state.passwordLinkState?.password == null) { + return Container(); + } else { + return ProcessingDialog(text: L10n.global().createShareProgressText); + } + }, + ); + } + + Future _askDetails() async { + if (_bloc.files.length == 1) { + final result = await showDialog( + context: context, + builder: (context) => SimpleInputDialog( + hintText: L10n.global().passwordInputHint, + buttonText: MaterialLocalizations.of(context).okButtonLabel, + validator: (value) { + if (value?.isNotEmpty != true) { + return L10n.global().passwordInputInvalidEmpty; + } + return null; + }, + obscureText: true, + ), + ); + if (!mounted) { + return; + } + if (result == null) { + _bloc.add(const _SetResult(false)); + return; + } else { + _bloc.add(_SetPasswordLinkDetails(password: result)); + } + } else { + final result = await showDialog( + context: context, + builder: (context) => const ShareLinkMultipleFilesDialog( + shouldAskPassword: true, + ), + ); + if (!mounted) { + return; + } + if (result == null) { + _bloc.add(const _SetResult(false)); + return; + } else { + _bloc.add(_SetPasswordLinkDetails( + albumName: result.albumName, + password: result.password!, + )); + } + } + } + + late final _bloc = context.read<_Bloc>(); +} diff --git a/app/lib/widget/file_sharer.g.dart b/app/lib/widget/file_sharer.g.dart new file mode 100644 index 00000000..161428fc --- /dev/null +++ b/app/lib/widget/file_sharer.g.dart @@ -0,0 +1,212 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_sharer.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {ShareMethod? method, + _PreviewState? previewState, + _FileState? fileState, + _PublicLinkState? publicLinkState, + _PasswordLinkState? passwordLinkState, + bool? result, + ExceptionEvent? error, + String? message}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic method = copyWithNull, + dynamic previewState = copyWithNull, + dynamic fileState = copyWithNull, + dynamic publicLinkState = copyWithNull, + dynamic passwordLinkState = copyWithNull, + dynamic result = copyWithNull, + dynamic error = copyWithNull, + dynamic message = copyWithNull}) { + return _State( + method: method == copyWithNull ? that.method : method as ShareMethod?, + previewState: previewState == copyWithNull + ? that.previewState + : previewState as _PreviewState?, + fileState: fileState == copyWithNull + ? that.fileState + : fileState as _FileState?, + publicLinkState: publicLinkState == copyWithNull + ? that.publicLinkState + : publicLinkState as _PublicLinkState?, + passwordLinkState: passwordLinkState == copyWithNull + ? that.passwordLinkState + : passwordLinkState as _PasswordLinkState?, + result: result == copyWithNull ? that.result : result as bool?, + error: error == copyWithNull ? that.error : error as ExceptionEvent?, + message: message == copyWithNull ? that.message : message as String?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +abstract class $_PreviewStateCopyWithWorker { + _PreviewState call({int? index, int? count}); +} + +class _$_PreviewStateCopyWithWorkerImpl + implements $_PreviewStateCopyWithWorker { + _$_PreviewStateCopyWithWorkerImpl(this.that); + + @override + _PreviewState call({dynamic index, dynamic count}) { + return _PreviewState( + index: index as int? ?? that.index, count: count as int? ?? that.count); + } + + final _PreviewState that; +} + +extension $_PreviewStateCopyWith on _PreviewState { + $_PreviewStateCopyWithWorker get copyWith => _$copyWith; + $_PreviewStateCopyWithWorker get _$copyWith => + _$_PreviewStateCopyWithWorkerImpl(this); +} + +abstract class $_FileStateCopyWithWorker { + _FileState call({int? index, int? count}); +} + +class _$_FileStateCopyWithWorkerImpl implements $_FileStateCopyWithWorker { + _$_FileStateCopyWithWorkerImpl(this.that); + + @override + _FileState call({dynamic index, dynamic count}) { + return _FileState( + index: index as int? ?? that.index, count: count as int? ?? that.count); + } + + final _FileState that; +} + +extension $_FileStateCopyWith on _FileState { + $_FileStateCopyWithWorker get copyWith => _$copyWith; + $_FileStateCopyWithWorker get _$copyWith => + _$_FileStateCopyWithWorkerImpl(this); +} + +abstract class $_PasswordLinkStateCopyWithWorker { + _PasswordLinkState call({String? password}); +} + +class _$_PasswordLinkStateCopyWithWorkerImpl + implements $_PasswordLinkStateCopyWithWorker { + _$_PasswordLinkStateCopyWithWorkerImpl(this.that); + + @override + _PasswordLinkState call({dynamic password = copyWithNull}) { + return _PasswordLinkState( + password: + password == copyWithNull ? that.password : password as String?); + } + + final _PasswordLinkState that; +} + +extension $_PasswordLinkStateCopyWith on _PasswordLinkState { + $_PasswordLinkStateCopyWithWorker get copyWith => _$copyWith; + $_PasswordLinkStateCopyWithWorker get _$copyWith => + _$_PasswordLinkStateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.file_sharer._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {method: ${method == null ? null : "${method!.name}"}, previewState: $previewState, fileState: $fileState, publicLinkState: $publicLinkState, passwordLinkState: $passwordLinkState, result: $result, error: $error, message: $message}"; + } +} + +extension _$_PreviewStateToString on _PreviewState { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PreviewState {index: $index, count: $count}"; + } +} + +extension _$_FileStateToString on _FileState { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_FileState {index: $index, count: $count}"; + } +} + +extension _$_PublicLinkStateToString on _PublicLinkState { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PublicLinkState {}"; + } +} + +extension _$_PasswordLinkStateToString on _PasswordLinkState { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PasswordLinkState {password: $password}"; + } +} + +extension _$_SetMethodToString on _SetMethod { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetMethod {method: ${method.name}}"; + } +} + +extension _$_SetResultToString on _SetResult { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetResult {result: $result}"; + } +} + +extension _$_SetPublicLinkDetailsToString on _SetPublicLinkDetails { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPublicLinkDetails {albumName: $albumName}"; + } +} + +extension _$_SetPasswordLinkDetailsToString on _SetPasswordLinkDetails { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPasswordLinkDetails {albumName: $albumName, password: $password}"; + } +} diff --git a/app/lib/widget/file_sharer/bloc.dart b/app/lib/widget/file_sharer/bloc.dart new file mode 100644 index 00000000..26cb3744 --- /dev/null +++ b/app/lib/widget/file_sharer/bloc.dart @@ -0,0 +1,209 @@ +part of '../file_sharer.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> implements BlocTag { + _Bloc({ + required DiContainer container, + required this.account, + required this.files, + }) : _c = container, + super(_State.init()) { + on<_SetMethod>(_onSetMethod); + on<_SetResult>(_onSetResult); + on<_SetPublicLinkDetails>(_onSetPublicLinkDetails); + on<_SetPasswordLinkDetails>(_onSetPasswordLinkDetails); + } + + @override + String get tag => _log.fullName; + + Future _onSetMethod(_SetMethod ev, Emitter<_State> emit) async { + _log.info("$ev"); + emit(state.copyWith(method: ev.method)); + switch (ev.method) { + case ShareMethod.file: + return _doShareFile(emit); + case ShareMethod.preview: + return _doSharePreview(emit); + case ShareMethod.publicLink: + return _doSharePublicLink(emit); + case ShareMethod.passwordLink: + return _doSharePasswordLink(emit); + } + } + + void _onSetResult(_SetResult ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(result: ev.result)); + } + + Future _onSetPublicLinkDetails( + _SetPublicLinkDetails ev, Emitter<_State> emit) { + _log.info("$ev"); + return _doShareLink(emit, albumName: ev.albumName, password: null); + } + + Future _onSetPasswordLinkDetails( + _SetPasswordLinkDetails ev, Emitter<_State> emit) { + _log.info("$ev"); + return _doShareLink(emit, albumName: ev.albumName, password: ev.password); + } + + Future _doShareFile(Emitter<_State> emit) async { + assert(platform_k.isAndroid); + emit(state.copyWith( + previewState: _PreviewState(index: 0, count: files.length), + )); + final results = >[]; + for (final pair in files.withIndex()) { + final i = pair.item1, f = pair.item2; + emit(state.copyWith( + previewState: state.previewState?.copyWith(index: i), + )); + try { + final uri = await DownloadFile()(account, f, shouldNotify: false); + results.add(Tuple2(f, uri)); + } on PermissionException catch (e, stackTrace) { + _log.warning("[_doShareFile] Permission not granted"); + emit(state.copyWith(error: ExceptionEvent(e, stackTrace))); + emit(state.copyWith(result: false)); + return; + } catch (e, stackTrace) { + _log.shout("[_doShareFile] Failed while DownloadFile", e, stackTrace); + emit(state.copyWith(error: ExceptionEvent(e, stackTrace))); + } + } + if (results.isNotEmpty) { + final share = AndroidFileShare( + results.map((e) => e.item2 as String).toList(), + results.map((e) => e.item1.fdMime).toList(), + ); + unawaited(share.share()); + } + emit(state.copyWith(result: true)); + } + + Future _doSharePreview(Emitter<_State> emit) async { + assert(platform_k.isAndroid); + emit(state.copyWith( + previewState: _PreviewState(index: 0, count: files.length), + )); + final results = >[]; + for (final pair in files.withIndex()) { + final i = pair.item1, f = pair.item2; + emit(state.copyWith( + previewState: state.previewState?.copyWith(index: i), + )); + try { + final dynamic uri; + if (file_util.isSupportedImageFormat(f) && f.fdMime != "image/gif") { + uri = await DownloadPreview()(account, f); + } else { + uri = await DownloadFile()(account, f, shouldNotify: false); + } + results.add(Tuple2(f, uri)); + } catch (e, stackTrace) { + _log.shout( + "[_doSharePreview] Failed while DownloadPreview", e, stackTrace); + emit(state.copyWith(error: ExceptionEvent(e, stackTrace))); + } + } + if (results.isNotEmpty) { + final share = AndroidFileShare( + results.map((e) => e.item2 as String).toList(), + results.map((e) => e.item1.fdMime).toList(), + ); + unawaited(share.share()); + } + emit(state.copyWith(result: true)); + } + + Future _doSharePublicLink(Emitter<_State> emit) async { + emit(state.copyWith(publicLinkState: const _PublicLinkState())); + } + + void _doSharePasswordLink(Emitter<_State> emit) { + emit(state.copyWith(passwordLinkState: const _PasswordLinkState())); + } + + Future _doShareLink( + Emitter<_State> emit, { + required String? albumName, + String? password, + }) async { + try { + final files = await InflateFileDescriptor(_c)(account, this.files); + final File fileToShare; + if (files.length == 1) { + fileToShare = files.first; + } else { + _log.info("[_doShareLink] Share as folder: $albumName"); + final path = await _createDir(account, albumName!); + final count = await _copyFilesToDir(account, files, path); + if (count != files.length) { + emit(state.copyWith( + message: L10n.global() + .copyItemsFailureNotification(files.length - count), + )); + } + final dir = File(path: path, isCollection: true); + fileToShare = dir; + } + await _shareFileAsLink(account, fileToShare, password); + emit(state.copyWith(message: L10n.global().linkCopiedNotification)); + emit(state.copyWith(result: true)); + } catch (e, stackTrace) { + _log.severe("[_doShareLink] Uncaught exception", e, stackTrace); + emit(state.copyWith(error: ExceptionEvent(e, stackTrace))); + emit(state.copyWith(result: false)); + } + } + + Future _shareFileAsLink( + Account account, File file, String? password) async { + final share = await CreateLinkShare(_c.shareRepo)( + account, + file, + password: password, + ); + await Clipboard.setData(ClipboardData(text: share.url)); + + if (platform_k.isAndroid) { + final textShare = AndroidTextShare(share.url!); + unawaited(textShare.share()); + } + } + + Future _createDir(Account account, String name) async { + // add a intermediate dir to allow shared dirs having the same name. Since + // the dir names are public, we can't add random pre/suffix + final timestamp = clock.now().millisecondsSinceEpoch; + final random = Random().nextInt(0xFFFFFF); + final dirName = + "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, "0")}"; + final path = + "${remote_storage_util.getRemoteLinkSharesDir(account)}/$dirName/$name"; + await CreateDir(_c.fileRepo)(account, path); + return path; + } + + /// Copy [files] to dir and return the copied count + Future _copyFilesToDir( + Account account, List files, String dirPath) async { + var count = 0; + for (final f in files) { + try { + await Copy(_c.fileRepo)(account, f, "$dirPath/${f.filename}"); + ++count; + } catch (e, stackTrace) { + _log.severe( + "[_copyFilesToDir] Failed while copying file: $f", e, stackTrace); + } + } + return count; + } + + final DiContainer _c; + final Account account; + final List files; +} diff --git a/app/lib/widget/file_sharer/state_event.dart b/app/lib/widget/file_sharer/state_event.dart new file mode 100644 index 00000000..00a78aa9 --- /dev/null +++ b/app/lib/widget/file_sharer/state_event.dart @@ -0,0 +1,137 @@ +part of '../file_sharer.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + this.method, + this.previewState, + this.fileState, + this.publicLinkState, + this.passwordLinkState, + this.result, + this.error, + this.message, + }); + + factory _State.init() { + return const _State(); + } + + @override + String toString() => _$toString(); + + final ShareMethod? method; + final _PreviewState? previewState; + final _FileState? fileState; + final _PublicLinkState? publicLinkState; + final _PasswordLinkState? passwordLinkState; + final bool? result; + final ExceptionEvent? error; + final String? message; +} + +@genCopyWith +@toString +class _PreviewState { + const _PreviewState({ + required this.index, + required this.count, + }); + + @override + String toString() => _$toString(); + + final int index; + final int count; +} + +@genCopyWith +@toString +class _FileState { + const _FileState({ + required this.index, + required this.count, + }); + + @override + String toString() => _$toString(); + + final int index; + final int count; +} + +@toString +class _PublicLinkState { + const _PublicLinkState(); + + @override + String toString() => _$toString(); +} + +@genCopyWith +@toString +class _PasswordLinkState { + const _PasswordLinkState({ + this.password, + }); + + @override + String toString() => _$toString(); + + final String? password; +} + +abstract class _Event { + const _Event(); +} + +/// Set the share method to be used +@toString +class _SetMethod implements _Event { + const _SetMethod(this.method); + + @override + String toString() => _$toString(); + + final ShareMethod method; +} + +/// Set the result of the sharer and return it to the caller +@toString +class _SetResult implements _Event { + const _SetResult(this.result); + + @override + String toString() => _$toString(); + + final bool result; +} + +/// Set the details needed to share files as public link +@toString +class _SetPublicLinkDetails implements _Event { + const _SetPublicLinkDetails({ + this.albumName, + }); + + @override + String toString() => _$toString(); + + final String? albumName; +} + +/// Set the details needed to share files as password protected link +@toString +class _SetPasswordLinkDetails implements _Event { + const _SetPasswordLinkDetails({ + this.albumName, + required this.password, + }); + + @override + String toString() => _$toString(); + + final String? albumName; + final String password; +} diff --git a/app/lib/widget/file_sharer/type.dart b/app/lib/widget/file_sharer/type.dart new file mode 100644 index 00000000..f0d14d44 --- /dev/null +++ b/app/lib/widget/file_sharer/type.dart @@ -0,0 +1,8 @@ +part of '../file_sharer.dart'; + +enum ShareMethod { + file, + preview, + publicLink, + passwordLink, +} diff --git a/app/lib/widget/handler/add_selection_to_album_handler.dart b/app/lib/widget/handler/add_selection_to_album_handler.dart deleted file mode 100644 index 2c0d9c2a..00000000 --- a/app/lib/widget/handler/add_selection_to_album_handler.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:clock/clock.dart'; -import 'package:flutter/material.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/notified_action.dart'; -import 'package:nc_photos/use_case/add_to_album.dart'; -import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; -import 'package:nc_photos/widget/album_picker.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'add_selection_to_album_handler.g.dart'; - -@npLog -class AddSelectionToAlbumHandler { - AddSelectionToAlbumHandler(this._c) - : assert(require(_c)), - assert(InflateFileDescriptor.require(_c)); - - static bool require(DiContainer c) => true; - - Future call({ - required BuildContext context, - required Account account, - required List selection, - required VoidCallback clearSelection, - }) async { - try { - final value = await Navigator.of(context).pushNamed( - AlbumPicker.routeName, - arguments: AlbumPickerArguments(account)); - if (value == null) { - // user cancelled the dialog - return; - } - - _log.info("[call] Album picked: ${value.name}"); - await NotifiedAction( - () async { - assert(value.provider is AlbumStaticProvider); - final selectedFiles = - await InflateFileDescriptor(_c)(account, selection); - final selected = selectedFiles - .map((f) => AlbumFileItem( - addedBy: account.userId, - addedAt: clock.now(), - file: f, - )) - .toList(); - clearSelection(); - await AddToAlbum(KiwiContainer().resolve())( - account, value, selected); - }, - null, - L10n.global().addSelectedToAlbumSuccessNotification(value.name), - failureText: L10n.global().addSelectedToAlbumFailureNotification, - )(); - } catch (e, stackTrace) { - _log.shout("[call] Exception", e, stackTrace); - } - } - - final DiContainer _c; -} diff --git a/app/lib/widget/handler/add_selection_to_collection_handler.dart b/app/lib/widget/handler/add_selection_to_collection_handler.dart new file mode 100644 index 00000000..e5a582a3 --- /dev/null +++ b/app/lib/widget/handler/add_selection_to_collection_handler.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/widget/collection_picker.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'add_selection_to_collection_handler.g.dart'; + +@npLog +class AddSelectionToCollectionHandler { + const AddSelectionToCollectionHandler(); + + Future call({ + required BuildContext context, + required List selection, + VoidCallback? clearSelection, + }) async { + try { + final collection = await Navigator.of(context) + .pushNamed(CollectionPicker.routeName); + if (collection == null) { + return; + } + _log.info("[call] Collection picked: ${collection.name}"); + + clearSelection?.call(); + final controller = context + .read() + .collectionsController + .stream + .value + .itemsControllerByCollection(collection); + Object? error; + final s = controller.stream.listen((_) {}, onError: (e) => error = e); + await controller.addFiles(selection); + await s.cancel(); + if (error != null) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().addSelectedToAlbumFailureNotification), + duration: k.snackBarDurationNormal, + )); + } + } catch (e, stackTrace) { + _log.shout("[call] Exception", e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().addSelectedToAlbumFailureNotification), + duration: k.snackBarDurationNormal, + )); + } + } +} diff --git a/app/lib/widget/handler/add_selection_to_album_handler.g.dart b/app/lib/widget/handler/add_selection_to_collection_handler.g.dart similarity index 56% rename from app/lib/widget/handler/add_selection_to_album_handler.g.dart rename to app/lib/widget/handler/add_selection_to_collection_handler.g.dart index 41295342..f5e4ec1f 100644 --- a/app/lib/widget/handler/add_selection_to_album_handler.g.dart +++ b/app/lib/widget/handler/add_selection_to_collection_handler.g.dart @@ -1,15 +1,16 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'add_selection_to_album_handler.dart'; +part of 'add_selection_to_collection_handler.dart'; // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$AddSelectionToAlbumHandlerNpLog on AddSelectionToAlbumHandler { +extension _$AddSelectionToCollectionHandlerNpLog + on AddSelectionToCollectionHandler { // ignore: unused_element Logger get _log => log; static final log = Logger( - "widget.handler.add_selection_to_album_handler.AddSelectionToAlbumHandler"); + "widget.handler.add_selection_to_collection_handler.AddSelectionToCollectionHandler"); } diff --git a/app/lib/widget/handler/remove_selection_handler.dart b/app/lib/widget/handler/remove_selection_handler.dart index 4819c9d8..3959abcb 100644 --- a/app/lib/widget/handler/remove_selection_handler.dart +++ b/app/lib/widget/handler/remove_selection_handler.dart @@ -61,9 +61,9 @@ class RemoveSelectionHandler { await Remove(KiwiContainer().resolve())( account, selectedFiles, - onRemoveFileFailed: (file, e, stackTrace) { + onError: (_, file, e, stackTrace) { _log.shout( - "[call] Failed while removing file: ${logFilename(file.path)}", + "[call] Failed while removing file: ${logFilename(file.fdPath)}", e, stackTrace); ++failureCount; diff --git a/app/lib/widget/home.dart b/app/lib/widget/home.dart index d137d967..5a1a4638 100644 --- a/app/lib/widget/home.dart +++ b/app/lib/widget/home.dart @@ -14,7 +14,7 @@ import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/import_potential_shared_album.dart'; -import 'package:nc_photos/widget/home_albums.dart'; +import 'package:nc_photos/widget/home_collections.dart'; import 'package:nc_photos/widget/home_photos.dart'; import 'package:nc_photos/widget/home_search.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -137,9 +137,7 @@ class _HomeState extends State with TickerProviderStateMixin { ); case 2: - return HomeAlbums( - account: widget.account, - ); + return const HomeCollections(); default: throw ArgumentError("Invalid page index: $index"); diff --git a/app/lib/widget/home_albums.dart b/app/lib/widget/home_albums.dart deleted file mode 100644 index 36dc527f..00000000 --- a/app/lib/widget/home_albums.dart +++ /dev/null @@ -1,653 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/bloc/list_album.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/album_util.dart' as album_util; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/exception_util.dart' as exception_util; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/material3.dart'; -import 'package:nc_photos/object_extension.dart'; -import 'package:nc_photos/platform/features.dart' as features; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/use_case/remove_album.dart'; -import 'package:nc_photos/use_case/unimport_shared_album.dart'; -import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util; -import 'package:nc_photos/widget/album_importer.dart'; -import 'package:nc_photos/widget/archive_browser.dart'; -import 'package:nc_photos/widget/builder/album_grid_item_builder.dart'; -import 'package:nc_photos/widget/dynamic_album_browser.dart'; -import 'package:nc_photos/widget/enhanced_photo_browser.dart'; -import 'package:nc_photos/widget/fancy_option_picker.dart'; -import 'package:nc_photos/widget/handler/double_tap_exit_handler.dart'; -import 'package:nc_photos/widget/home_app_bar.dart'; -import 'package:nc_photos/widget/new_album_dialog.dart'; -import 'package:nc_photos/widget/page_visibility_mixin.dart'; -import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; -import 'package:nc_photos/widget/selection_app_bar.dart'; -import 'package:nc_photos/widget/sharing_browser.dart'; -import 'package:nc_photos/widget/trashbin_browser.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'home_albums.g.dart'; - -class HomeAlbums extends StatefulWidget { - const HomeAlbums({ - Key? key, - required this.account, - }) : super(key: key); - - @override - createState() => _HomeAlbumsState(); - - final Account account; -} - -@npLog -class _HomeAlbumsState extends State - with - SelectableItemStreamListMixin, - RouteAware, - PageVisibilityMixin { - @override - initState() { - super.initState(); - _initBloc(); - _accountPrefUpdatedEventListener.begin(); - } - - @override - dispose() { - _accountPrefUpdatedEventListener.end(); - super.dispose(); - } - - @override - build(BuildContext context) { - return BlocListener( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as<_ListItem>()?.onTap?.call(); - } - - @override - onBackButtonPressed() async { - if (isSelectionMode) { - return super.onBackButtonPressed(); - } else { - return DoubleTapExitHandler()(); - } - } - - void _initBloc() { - if (_bloc.state is ListAlbumBlocInit) { - _log.info("[_initBloc] Initialize bloc"); - _reqQuery(); - } else { - // process the current state - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _onStateChange(context, _bloc.state); - }); - } - }); - } - } - - Widget _buildContent(BuildContext context, ListAlbumBlocState state) { - return Stack( - children: [ - buildItemStreamListOuter( - context, - child: RefreshIndicator( - onRefresh: () async { - _onRefreshPressed(); - await _waitRefresh(); - }, - child: CustomScrollView( - slivers: [ - _buildAppBar(context), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: buildItemStreamList( - maxCrossAxisExtent: 256, - childBorderRadius: BorderRadius.zero, - indicatorAlignment: const Alignment(-.92, -.92), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: _calcBottomAppBarExtent(context), - ), - ), - ], - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - width: double.infinity, - height: _calcBottomAppBarExtent(context), - child: ClipRect( - child: BackdropFilter( - filter: Theme.of(context).appBarBlurFilter, - child: const ColoredBox( - color: Colors.transparent, - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildAppBar(BuildContext context) { - if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return _buildNormalAppBar(context); - } - } - - Widget _buildSelectionAppBar(BuildContext conetxt) { - return SelectionAppBar( - count: selectedListItems.length, - onClosePressed: () { - setState(() { - clearSelectedItems(); - }); - }, - actions: [ - IconButton( - icon: const Icon(Icons.delete), - tooltip: L10n.global().deleteTooltip, - onPressed: () { - _onSelectionDeletePressed(); - }, - ), - ], - ); - } - - Widget _buildNormalAppBar(BuildContext context) { - return BlocBuilder( - bloc: _bloc, - buildWhen: (previous, current) => - previous is ListAlbumBlocLoading != current is ListAlbumBlocLoading, - builder: (context, state) { - return HomeSliverAppBar( - account: widget.account, - isShowProgressIcon: state is ListAlbumBlocLoading, - menuActions: [ - PopupMenuItem( - value: _menuValueSort, - child: Text(L10n.global().sortTooltip), - ), - PopupMenuItem( - value: _menuValueImport, - child: Text(L10n.global().importFoldersTooltip), - ), - ], - onSelectedMenuActions: (option) { - switch (option) { - case _menuValueSort: - _onSortPressed(context); - break; - - case _menuValueImport: - _onImportPressed(context); - break; - } - }, - ); - }, - ); - } - - SelectableItem _buildArchiveItem(BuildContext context) { - return _ButtonListItem( - icon: Icons.archive_outlined, - label: L10n.global().albumArchiveLabel, - onTap: () { - if (!isSelectionMode) { - Navigator.of(context).pushNamed(ArchiveBrowser.routeName, - arguments: ArchiveBrowserArguments(widget.account)); - } - }, - ); - } - - SelectableItem _buildTrashbinItem(BuildContext context) { - return _ButtonListItem( - icon: Icons.delete_outlined, - label: L10n.global().albumTrashLabel, - onTap: () { - if (!isSelectionMode) { - Navigator.of(context).pushNamed(TrashbinBrowser.routeName, - arguments: TrashbinBrowserArguments(widget.account)); - } - }, - ); - } - - SelectableItem _buildSharingItem(BuildContext context) { - return _ButtonListItem( - icon: Icons.share_outlined, - label: L10n.global().collectionSharingLabel, - isShowIndicator: AccountPref.of(widget.account).hasNewSharedAlbumOr(), - onTap: () { - if (!isSelectionMode) { - Navigator.of(context).pushNamed(SharingBrowser.routeName, - arguments: SharingBrowserArguments(widget.account)); - } - }, - ); - } - - SelectableItem _buildEnhancedPhotosItem(BuildContext context) { - return _ButtonListItem( - icon: Icons.auto_fix_high_outlined, - label: L10n.global().collectionEditedPhotosLabel, - onTap: () { - if (!isSelectionMode) { - Navigator.of(context).pushNamed(EnhancedPhotoBrowser.routeName, - arguments: const EnhancedPhotoBrowserArguments(null)); - } - }, - ); - } - - SelectableItem _buildNewAlbumItem(BuildContext context) { - return _ButtonListItem( - icon: Icons.add, - label: L10n.global().createCollectionTooltip, - onTap: () { - if (!isSelectionMode) { - _onNewAlbumItemTap(context); - } - }, - ); - } - - void _onStateChange(BuildContext context, ListAlbumBlocState state) { - if (state is ListAlbumBlocInit) { - itemStreamListItems = []; - } else if (state is ListAlbumBlocSuccess || state is ListAlbumBlocLoading) { - _transformItems(state.items); - } else if (state is ListAlbumBlocFailure) { - _transformItems(state.items); - if (isPageVisible()) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } - } else if (state is ListAlbumBlocInconsistent) { - _reqQuery(); - } - } - - Future _onNewAlbumItemTap(BuildContext context) async { - try { - final album = await showDialog( - context: context, - builder: (_) => NewAlbumDialog( - account: widget.account, - ), - ); - if (album == null) { - return; - } - if (album.provider is AlbumDynamicProvider) { - // open the album automatically to refresh its content, otherwise it'll - // be empty - unawaited( - Navigator.of(context).pushNamed(DynamicAlbumBrowser.routeName, - arguments: DynamicAlbumBrowserArguments(widget.account, album)), - ); - } - } catch (e, stacktrace) { - _log.shout("[_onNewAlbumItemTap] Failed", e, stacktrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().createAlbumFailureNotification), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onRefreshPressed() { - _reqQuery(); - } - - void _onSortPressed(BuildContext context) { - showDialog( - context: context, - builder: (context) => FancyOptionPicker( - title: L10n.global().sortOptionDialogTitle, - items: [ - FancyOptionPickerItem( - label: L10n.global().sortOptionTimeDescendingLabel, - isSelected: - _getSortFromPref() == album_util.AlbumSort.dateDescending, - onSelect: () { - _onSortSelected(album_util.AlbumSort.dateDescending); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionTimeAscendingLabel, - isSelected: - _getSortFromPref() == album_util.AlbumSort.dateAscending, - onSelect: () { - _onSortSelected(album_util.AlbumSort.dateAscending); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionAlbumNameLabel, - isSelected: - _getSortFromPref() == album_util.AlbumSort.nameAscending, - onSelect: () { - _onSortSelected(album_util.AlbumSort.nameAscending); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionAlbumNameDescendingLabel, - isSelected: - _getSortFromPref() == album_util.AlbumSort.nameDescending, - onSelect: () { - _onSortSelected(album_util.AlbumSort.nameDescending); - Navigator.of(context).pop(); - }, - ), - ], - ), - ); - } - - Future _onSortSelected(album_util.AlbumSort sort) async { - await Pref().setHomeAlbumsSort(sort.index); - setState(() { - _transformItems(_bloc.state.items); - }); - } - - void _onImportPressed(BuildContext context) { - Navigator.of(context).pushNamed(AlbumImporter.routeName, - arguments: AlbumImporterArguments(widget.account)); - } - - Future _onSelectionDeletePressed() async { - final selected = selectedListItems - .whereType<_AlbumListItem>() - .map((e) => e.album) - .toList(); - SnackBarManager().showSnackBar(SnackBar( - content: Text( - L10n.global().deleteSelectedProcessingNotification(selected.length)), - duration: k.snackBarDurationShort, - )); - setState(() { - clearSelectedItems(); - }); - final failures = []; - for (final a in selected) { - try { - if (a.albumFile?.isOwned(widget.account.userId) == true) { - // delete owned albums - await RemoveAlbum(KiwiContainer().resolve())( - widget.account, a); - } else { - // remove shared albums from collection - await UnimportSharedAlbum(KiwiContainer().resolve())( - widget.account, a); - } - } catch (e, stackTrace) { - _log.shout( - "[_onSelectionDeletePressed] Failed while removing album: '${a.name}'", - e, - stackTrace); - failures.add(a); - } - } - if (failures.isEmpty) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().deleteSelectedSuccessNotification), - duration: k.snackBarDurationNormal, - )); - } else { - SnackBarManager().showSnackBar(SnackBar( - content: Text( - L10n.global().deleteSelectedFailureNotification(failures.length)), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onAccountPrefUpdatedEvent(AccountPrefUpdatedEvent ev) { - if (ev.key == PrefKey.isEnableFaceRecognitionApp || - ev.key == PrefKey.hasNewSharedAlbum) { - if (identical(ev.pref, AccountPref.of(widget.account))) { - setState(() { - _transformItems(_bloc.state.items); - }); - } - } - } - - /// Transform an Album list to grid items - void _transformItems(List items) { - final sort = _getSortFromPref(); - final sortedAlbums = - album_util.sorted(items.map((e) => e.album).toList(), sort); - itemStreamListItems = [ - _buildSharingItem(context), - if (features.isSupportEnhancement) _buildEnhancedPhotosItem(context), - _buildArchiveItem(context), - _buildTrashbinItem(context), - _buildNewAlbumItem(context), - _SeparatorListItem(), - ...sortedAlbums.map((a) => _AlbumListItem( - account: widget.account, - album: a, - onTap: () { - _openAlbum(context, a); - }, - )), - ]; - } - - void _openAlbum(BuildContext context, Album album) { - album_browser_util.push(context, widget.account, album); - } - - void _reqQuery() { - _bloc.add(ListAlbumBlocQuery(widget.account)); - } - - Future _waitRefresh() async { - while (true) { - await Future.delayed(const Duration(seconds: 1)); - if (_bloc.state is! ListAlbumBlocLoading) { - return; - } - } - } - - double _calcBottomAppBarExtent(BuildContext context) => - NavigationBarTheme.of(context).height!; - - late final _bloc = ListAlbumBloc.of(widget.account); - late final _accountPrefUpdatedEventListener = - AppEventListener(_onAccountPrefUpdatedEvent); - - static const _menuValueImport = 0; - static const _menuValueSort = 1; -} - -abstract class _ListItem implements SelectableItem { - _ListItem({ - VoidCallback? onTap, - }) : _myOnTap = onTap; - - @override - get isTappable => _myOnTap != null; - - get onTap => _myOnTap; - - @override - get isSelectable => true; - - @override - get staggeredTile => const StaggeredTile.count(1, 1); - - final VoidCallback? _myOnTap; -} - -class _ButtonListItem extends _ListItem { - _ButtonListItem({ - required this.icon, - required this.label, - VoidCallback? onTap, - this.isShowIndicator = false, - }) : _onTap = onTap; - - @override - get isSelectable => false; - - @override - get staggeredTile => const StaggeredTile.fit(1); - - @override - Widget buildWidget(BuildContext context) => _ButtonListItemView( - icon: icon, - label: label, - onTap: _onTap, - isShowIndicator: isShowIndicator, - ); - - final IconData icon; - final String label; - final bool isShowIndicator; - - final VoidCallback? _onTap; -} - -class _ButtonListItemView extends StatelessWidget { - const _ButtonListItemView({ - required this.icon, - required this.label, - this.onTap, - this.isShowIndicator = false, - }); - - @override - Widget build(BuildContext context) { - return Theme( - data: Theme.of(context).copyWith( - canvasColor: M3.of(context).assistChip.enabled.container, - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: ActionChip( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - labelPadding: const EdgeInsetsDirectional.fromSTEB(8, 0, 0, 0), - // specify icon size explicitly to workaround size flickering during - // theme transition - avatar: Icon(icon, size: 18), - label: Row( - children: [ - Expanded(child: Text(label)), - if (isShowIndicator) - Icon( - Icons.circle, - color: Theme.of(context).colorScheme.tertiary, - size: 8, - ), - ], - ), - onPressed: onTap, - ), - ), - ); - } - - final IconData icon; - final String label; - final VoidCallback? onTap; - final bool isShowIndicator; -} - -class _SeparatorListItem extends _ListItem { - @override - get isSelectable => false; - - @override - get staggeredTile => const StaggeredTile.extent(99, 1); - - @override - buildWidget(BuildContext context) => Container(); -} - -class _AlbumListItem extends _ListItem { - _AlbumListItem({ - required this.account, - required this.album, - VoidCallback? onTap, - }) : super(onTap: onTap); - - @override - operator ==(Object other) { - return other is _AlbumListItem && - album.albumFile!.path == other.album.albumFile!.path; - } - - @override - get hashCode => album.albumFile!.path.hashCode; - - @override - buildWidget(BuildContext context) { - return AlbumGridItemBuilder( - account: account, - album: album, - isShared: album.shares?.isNotEmpty == true, - ).build(context); - } - - final Account account; - final Album album; -} - -album_util.AlbumSort _getSortFromPref() { - try { - return album_util.AlbumSort.values[Pref().getHomeAlbumsSort()!]; - } catch (_) { - // default - return album_util.AlbumSort.dateDescending; - } -} diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart new file mode 100644 index 00000000..9962dbb9 --- /dev/null +++ b/app/lib/widget/home_collections.dart @@ -0,0 +1,569 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/entity/collection/util.dart' as collection_util; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/np_api_util.dart'; +import 'package:nc_photos/platform/features.dart' as features; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/album_importer.dart'; +import 'package:nc_photos/widget/archive_browser.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; +import 'package:nc_photos/widget/collection_grid_item.dart'; +import 'package:nc_photos/widget/enhanced_photo_browser.dart'; +import 'package:nc_photos/widget/fancy_option_picker.dart'; +import 'package:nc_photos/widget/home_app_bar.dart'; +import 'package:nc_photos/widget/navigation_bar_blur_filter.dart'; +import 'package:nc_photos/widget/new_collection_dialog.dart'; +import 'package:nc_photos/widget/page_visibility_mixin.dart'; +import 'package:nc_photos/widget/selectable_item_list.dart'; +import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos/widget/sharing_browser.dart'; +import 'package:nc_photos/widget/trashbin_browser.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'home_collections.g.dart'; +part 'home_collections/bloc.dart'; +part 'home_collections/state_event.dart'; +part 'home_collections/type.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; + +/// Show and manage a list of [Collection]s +class HomeCollections extends StatelessWidget { + const HomeCollections({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + account: context.read().account, + controller: context.read().collectionsController, + prefController: context.read(), + ), + child: const _WrappedHomeCollections(), + ); + } +} + +class _WrappedHomeCollections extends StatefulWidget { + const _WrappedHomeCollections(); + + @override + State createState() => _WrappedHomeCollectionsState(); +} + +@npLog +class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> + with RouteAware, PageVisibilityMixin { + @override + void initState() { + super.initState(); + _bloc.add(const _LoadCollections()); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.collections != current.collections, + listener: (context, state) { + _bloc.add(_TransformItems(state.collections)); + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.loadError != current.loadError, + listener: (context, state) { + if (state.loadError != null && isPageVisible()) { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(exception_util.toUserString(state.loadError!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.removeError != current.removeError, + listener: (context, state) { + if (state.removeError != null && isPageVisible()) { + SnackBarManager().showSnackBar(const SnackBar( + content: Text('Failed to remove some collections'), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: Stack( + children: [ + CustomScrollView( + slivers: [ + _BlocBuilder( + buildWhen: (previous, current) => + previous.selectedItems.isEmpty != + current.selectedItems.isEmpty, + builder: (context, state) => state.selectedItems.isEmpty + ? const _AppBar() + : const _SelectionAppBar(), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: _BlocBuilder( + buildWhen: (previous, current) => + previous.selectedItems.isEmpty != + current.selectedItems.isEmpty, + builder: (context, state) => _ButtonGrid( + account: _bloc.account, + isEnabled: state.selectedItems.isEmpty, + onSharingPressed: () { + Navigator.of(context).pushNamed(SharingBrowser.routeName, + arguments: SharingBrowserArguments(_bloc.account)); + }, + onEnhancedPhotosPressed: () { + Navigator.of(context).pushNamed( + EnhancedPhotoBrowser.routeName, + arguments: const EnhancedPhotoBrowserArguments(null)); + }, + onArchivePressed: () { + Navigator.of(context).pushNamed(ArchiveBrowser.routeName, + arguments: ArchiveBrowserArguments(_bloc.account)); + }, + onTrashbinPressed: () { + Navigator.of(context).pushNamed(TrashbinBrowser.routeName, + arguments: TrashbinBrowserArguments(_bloc.account)); + }, + onNewCollectionPressed: () { + _onNewCollectionPressed(context); + }, + ), + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 8), + ), + _BlocBuilder( + buildWhen: (previous, current) => + previous.transformedItems != current.transformedItems || + previous.selectedItems != current.selectedItems, + builder: (context, state) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: SelectableItemList( + maxCrossAxisExtent: 256, + childBorderRadius: BorderRadius.zero, + indicatorAlignment: const Alignment(-.92, -.92), + items: state.transformedItems, + itemBuilder: (_, __, metadata) { + final item = metadata as _Item; + return _ItemView( + account: _bloc.account, + item: item, + ); + }, + staggeredTileBuilder: (_, __) => + const StaggeredTile.count(1, 1), + selectedItems: state.selectedItems, + onSelectionChange: (_, selected) { + _bloc.add(_SetSelectedItems(items: selected.cast())); + }, + onItemTap: (context, _, metadata) { + final item = metadata as _Item; + Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(item.collection), + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: NavigationBarTheme.of(context).height, + ), + ), + ], + ), + const Align( + alignment: Alignment.bottomCenter, + child: NavigationBarBlurFilter(), + ), + ], + ), + ); + } + + Future _onNewCollectionPressed(BuildContext context) async { + try { + final collection = await showDialog( + context: context, + builder: (_) => NewCollectionDialog( + account: _bloc.account, + ), + ); + if (collection == null) { + return; + } + // open the newly created collection + unawaited(Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(collection), + )); + } catch (e, stacktrace) { + _log.shout("[_onNewCollectionPressed] Failed", e, stacktrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().createAlbumFailureNotification), + duration: k.snackBarDurationNormal, + )); + } + } + + late final _Bloc _bloc = context.read(); +} + +class _AppBar extends StatelessWidget { + const _AppBar(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => previous.isLoading != current.isLoading, + builder: (context, state) => HomeSliverAppBar( + account: context.read<_Bloc>().account, + isShowProgressIcon: state.isLoading, + menuActions: [ + PopupMenuItem( + value: _menuValueSort, + child: Text(L10n.global().sortTooltip), + ), + PopupMenuItem( + value: _menuValueImport, + child: Text(L10n.global().importFoldersTooltip), + ), + ], + onSelectedMenuActions: (option) { + switch (option) { + case _menuValueSort: + _onSortPressed(context); + break; + + case _menuValueImport: + _onImportPressed(context); + break; + } + }, + ), + ); + } + + Future _onSortPressed(BuildContext context) async { + final sort = context.read<_Bloc>().state.sort; + final result = await showDialog( + context: context, + builder: (context) => FancyOptionPicker( + title: L10n.global().sortOptionDialogTitle, + items: [ + FancyOptionPickerItem( + label: L10n.global().sortOptionTimeDescendingLabel, + isSelected: sort == collection_util.CollectionSort.dateDescending, + onSelect: () { + Navigator.of(context) + .pop(collection_util.CollectionSort.dateDescending); + }, + ), + FancyOptionPickerItem( + label: L10n.global().sortOptionTimeAscendingLabel, + isSelected: sort == collection_util.CollectionSort.dateAscending, + onSelect: () { + Navigator.of(context) + .pop(collection_util.CollectionSort.dateAscending); + }, + ), + FancyOptionPickerItem( + label: L10n.global().sortOptionAlbumNameLabel, + isSelected: sort == collection_util.CollectionSort.nameAscending, + onSelect: () { + Navigator.of(context) + .pop(collection_util.CollectionSort.nameAscending); + }, + ), + FancyOptionPickerItem( + label: L10n.global().sortOptionAlbumNameDescendingLabel, + isSelected: sort == collection_util.CollectionSort.nameDescending, + onSelect: () { + Navigator.of(context) + .pop(collection_util.CollectionSort.nameDescending); + }, + ), + ], + ), + ); + if (result == null) { + return; + } + context.read<_Bloc>().add(_SetCollectionSort(result)); + } + + void _onImportPressed(BuildContext context) { + Navigator.of(context).pushNamed(AlbumImporter.routeName, + arguments: AlbumImporterArguments(context.read<_Bloc>().account)); + } + + static const _menuValueImport = 0; + static const _menuValueSort = 1; +} + +@npLog +class _SelectionAppBar extends StatelessWidget { + const _SelectionAppBar(); + + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.selectedItems != current.selectedItems, + builder: (context, state) => SelectionAppBar( + count: state.selectedItems.length, + onClosePressed: () { + context.read<_Bloc>().add(const _SetSelectedItems(items: {})); + }, + actions: [ + IconButton( + icon: const Icon(Icons.delete), + tooltip: L10n.global().deleteTooltip, + onPressed: () { + context.read<_Bloc>().add(const _RemoveSelectedItems()); + }, + ), + ], + ), + ); + } +} + +class _ButtonGrid extends StatelessWidget { + const _ButtonGrid({ + required this.account, + required this.isEnabled, + this.onSharingPressed, + this.onEnhancedPhotosPressed, + this.onArchivePressed, + this.onTrashbinPressed, + this.onNewCollectionPressed, + }); + + @override + Widget build(BuildContext context) { + return SliverStaggeredGrid.extent( + maxCrossAxisExtent: 256, + staggeredTiles: List.filled(5, const StaggeredTile.fit(1)), + children: [ + _ButtonGridItemView( + icon: Icons.share_outlined, + label: L10n.global().collectionSharingLabel, + isShowIndicator: AccountPref.of(account).hasNewSharedAlbumOr(), + isEnabled: isEnabled, + onTap: () { + onSharingPressed?.call(); + }, + ), + if (features.isSupportEnhancement) + _ButtonGridItemView( + icon: Icons.auto_fix_high_outlined, + label: L10n.global().collectionEditedPhotosLabel, + isEnabled: isEnabled, + onTap: () { + onEnhancedPhotosPressed?.call(); + }, + ), + _ButtonGridItemView( + icon: Icons.archive_outlined, + label: L10n.global().albumArchiveLabel, + isEnabled: isEnabled, + onTap: () { + onArchivePressed?.call(); + }, + ), + _ButtonGridItemView( + icon: Icons.delete_outlined, + label: L10n.global().albumTrashLabel, + isEnabled: isEnabled, + onTap: () { + onTrashbinPressed?.call(); + }, + ), + _ButtonGridItemView( + icon: Icons.add, + label: L10n.global().createCollectionTooltip, + isEnabled: isEnabled, + onTap: () { + onNewCollectionPressed?.call(); + }, + ), + ], + ); + } + + final Account account; + final bool isEnabled; + final VoidCallback? onSharingPressed; + final VoidCallback? onEnhancedPhotosPressed; + final VoidCallback? onArchivePressed; + final VoidCallback? onTrashbinPressed; + final VoidCallback? onNewCollectionPressed; +} + +class _ButtonGridItemView extends StatelessWidget { + const _ButtonGridItemView({ + required this.icon, + required this.label, + this.isShowIndicator = false, + required this.isEnabled, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: ActionChip( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + labelPadding: const EdgeInsetsDirectional.fromSTEB(8, 0, 0, 0), + // specify icon size explicitly to workaround size flickering during + // theme transition + avatar: Icon(icon, size: 18), + label: Row( + children: [ + Expanded( + child: Text(label), + ), + if (isShowIndicator) + Icon( + Icons.circle, + color: Theme.of(context).colorScheme.tertiary, + size: 8, + ), + ], + ), + onPressed: isEnabled ? onTap : null, + ), + ); + } + + final IconData icon; + final String label; + final bool isShowIndicator; + final bool isEnabled; + final VoidCallback? onTap; +} + +class _ItemView extends StatelessWidget { + const _ItemView({ + required this.account, + required this.item, + }); + + @override + Widget build(BuildContext context) { + Widget? icon; + switch (item.itemType) { + case _ItemType.ncAlbum: + icon = const ImageIcon(AssetImage("assets/ic_nextcloud_album.png")); + break; + case _ItemType.album: + icon = null; + break; + case _ItemType.tagAlbum: + icon = const Icon(Icons.local_offer); + break; + case _ItemType.dirAlbum: + icon = const Icon(Icons.folder); + break; + } + return CollectionGridItem( + cover: _CollectionCover( + account: account, + url: item.coverUrl, + ), + title: item.name, + subtitle: item.subtitle, + icon: icon, + ); + } + + final Account account; + final _Item item; +} + +class _CollectionCover extends StatelessWidget { + const _CollectionCover({ + required this.account, + required this.url, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Theme.of(context).listPlaceholderBackgroundColor, + constraints: const BoxConstraints.expand(), + child: url != null + ? FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CachedNetworkImage( + cacheManager: CoverCacheManager.inst, + imageUrl: url!, + httpHeaders: { + "Authorization": + AuthUtil.fromAccount(account).toHeaderValue(), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + errorWidget: (context, url, error) { + // just leave it empty + return Container(); + }, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + ), + ) + : Icon( + Icons.panorama, + color: Theme.of(context).listPlaceholderForegroundColor, + size: 88, + ), + ), + ); + } + + final Account account; + final String? url; +} diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart new file mode 100644 index 00000000..9449109b --- /dev/null +++ b/app/lib/widget/home_collections.g.dart @@ -0,0 +1,145 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_collections.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List? collections, + collection_util.CollectionSort? sort, + bool? isLoading, + ExceptionEvent? loadError, + List<_Item>? transformedItems, + Set<_Item>? selectedItems, + ExceptionEvent? removeError}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic collections, + dynamic sort, + dynamic isLoading, + dynamic loadError = copyWithNull, + dynamic transformedItems, + dynamic selectedItems, + dynamic removeError = copyWithNull}) { + return _State( + collections: collections as List? ?? that.collections, + sort: sort as collection_util.CollectionSort? ?? that.sort, + isLoading: isLoading as bool? ?? that.isLoading, + loadError: loadError == copyWithNull + ? that.loadError + : loadError as ExceptionEvent?, + transformedItems: + transformedItems as List<_Item>? ?? that.transformedItems, + selectedItems: selectedItems as Set<_Item>? ?? that.selectedItems, + removeError: removeError == copyWithNull + ? that.removeError + : removeError as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedHomeCollectionsStateNpLog on _WrappedHomeCollectionsState { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.home_collections._WrappedHomeCollectionsState"); +} + +extension _$_SelectionAppBarNpLog on _SelectionAppBar { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.home_collections._SelectionAppBar"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.home_collections._Bloc"); +} + +extension _$_ItemNpLog on _Item { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.home_collections._Item"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {collections: [length: ${collections.length}], sort: ${sort.name}, isLoading: $isLoading, loadError: $loadError, transformedItems: [length: ${transformedItems.length}], selectedItems: $selectedItems, removeError: $removeError}"; + } +} + +extension _$_LoadCollectionsToString on _LoadCollections { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadCollections {}"; + } +} + +extension _$_TransformItemsToString on _TransformItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformItems {collections: [length: ${collections.length}]}"; + } +} + +extension _$_SetSelectedItemsToString on _SetSelectedItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetSelectedItems {items: $items}"; + } +} + +extension _$_RemoveSelectedItemsToString on _RemoveSelectedItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RemoveSelectedItems {}"; + } +} + +extension _$_UpdateCollectionSortToString on _UpdateCollectionSort { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_UpdateCollectionSort {sort: ${sort.name}}"; + } +} + +extension _$_SetCollectionSortToString on _SetCollectionSort { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetCollectionSort {sort: ${sort.name}}"; + } +} diff --git a/app/lib/widget/home_collections/bloc.dart b/app/lib/widget/home_collections/bloc.dart new file mode 100644 index 00000000..945012bb --- /dev/null +++ b/app/lib/widget/home_collections/bloc.dart @@ -0,0 +1,101 @@ +part of '../home_collections.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> implements BlocTag { + _Bloc({ + required this.account, + required this.controller, + required this.prefController, + }) : super(_State.init()) { + on<_LoadCollections>(_onLoad); + on<_TransformItems>(_onTransformItems); + + on<_SetSelectedItems>(_onSetSelectedItems); + on<_RemoveSelectedItems>(_onRemoveSelectedItems); + + on<_UpdateCollectionSort>(_onUpdateCollectionSort); + on<_SetCollectionSort>(_onSetCollectionSort); + + _homeAlbumsSortSubscription = + prefController.homeAlbumsSort.distinct().listen((event) { + add(_UpdateCollectionSort(collection_util.CollectionSort.values[event])); + }); + } + + @override + Future close() { + _homeAlbumsSortSubscription?.cancel(); + return super.close(); + } + + @override + String get tag => _log.fullName; + + Future _onLoad(_LoadCollections ev, Emitter<_State> emit) async { + _log.info("[_onLoad] $ev"); + return emit.forEach( + controller.stream, + onData: (data) => state.copyWith( + collections: data.data.map((d) => d.collection).toList(), + isLoading: data.hasNext, + loadError: null, + ), + onError: (e, stackTrace) { + _log.severe("[_onLoad] Uncaught exception", e, stackTrace); + return state.copyWith( + isLoading: false, + loadError: ExceptionEvent(e, stackTrace), + ); + }, + ); + } + + Future _onTransformItems( + _TransformItems ev, Emitter<_State> emit) async { + _log.info("[_onTransformItems] $ev"); + final transformed = _transformCollections(ev.collections, state.sort); + emit(state.copyWith(transformedItems: transformed)); + } + + void _onSetSelectedItems(_SetSelectedItems ev, Emitter<_State> emit) { + _log.info("[_onSetSelectedItems] $ev"); + emit(state.copyWith(selectedItems: ev.items)); + } + + void _onRemoveSelectedItems(_RemoveSelectedItems ev, Emitter<_State> emit) { + _log.info("[_onDeleteSelectedItems] $ev"); + final selected = state.selectedItems; + emit(state.copyWith(selectedItems: const {})); + controller.remove(selected.map((e) => e.collection).toList()); + } + + void _onUpdateCollectionSort(_UpdateCollectionSort ev, Emitter<_State> emit) { + _log.info("[_onUpdateCollectionSort] $ev"); + if (ev.sort != state.sort) { + final transformed = _transformCollections(state.collections, ev.sort); + emit(state.copyWith( + transformedItems: transformed, + sort: ev.sort, + )); + } + } + + void _onSetCollectionSort(_SetCollectionSort ev, Emitter<_State> emit) { + _log.info("[_onSetCollectionSort] $ev"); + prefController.setHomeAlbumsSort(ev.sort.index); + } + + List<_Item> _transformCollections( + List collections, + collection_util.CollectionSort sort, + ) { + final sorted = collections.sortedBy(sort); + return sorted.map((c) => _Item(c)).toList(); + } + + final Account account; + final CollectionsController controller; + final PrefController prefController; + + StreamSubscription? _homeAlbumsSortSubscription; +} diff --git a/app/lib/widget/home_collections/state_event.dart b/app/lib/widget/home_collections/state_event.dart new file mode 100644 index 00000000..8b3ead29 --- /dev/null +++ b/app/lib/widget/home_collections/state_event.dart @@ -0,0 +1,106 @@ +part of '../home_collections.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.collections, + required this.sort, + required this.isLoading, + required this.loadError, + required this.transformedItems, + required this.selectedItems, + required this.removeError, + }); + + factory _State.init() { + return const _State( + collections: [], + sort: collection_util.CollectionSort.dateDescending, + isLoading: false, + loadError: null, + transformedItems: [], + selectedItems: {}, + removeError: null, + ); + } + + @override + String toString() => _$toString(); + + final List collections; + final collection_util.CollectionSort sort; + final bool isLoading; + final ExceptionEvent? loadError; + final List<_Item> transformedItems; + final Set<_Item> selectedItems; + + final ExceptionEvent? removeError; +} + +abstract class _Event { + const _Event(); +} + +/// Load the list of collections belonging to this account +@toString +class _LoadCollections implements _Event { + const _LoadCollections(); + + @override + String toString() => _$toString(); +} + +/// Transform the collection list (e.g., filtering, sorting, etc) +@toString +class _TransformItems implements _Event { + const _TransformItems(this.collections); + + @override + String toString() => _$toString(); + + final List collections; +} + +/// Set the currently selected items +@toString +class _SetSelectedItems implements _Event { + const _SetSelectedItems({ + required this.items, + }); + + @override + String toString() => _$toString(); + + final Set<_Item> items; +} + +/// Delete selected items +@toString +class _RemoveSelectedItems implements _Event { + const _RemoveSelectedItems(); + + @override + String toString() => _$toString(); +} + +/// Update collection sort due to external changes +@toString +class _UpdateCollectionSort implements _Event { + const _UpdateCollectionSort(this.sort); + + @override + String toString() => _$toString(); + + final collection_util.CollectionSort sort; +} + +@toString +class _SetCollectionSort implements _Event { + const _SetCollectionSort(this.sort); + + @override + String toString() => _$toString(); + + final collection_util.CollectionSort sort; +} diff --git a/app/lib/widget/home_collections/type.dart b/app/lib/widget/home_collections/type.dart new file mode 100644 index 00000000..6d00c92c --- /dev/null +++ b/app/lib/widget/home_collections/type.dart @@ -0,0 +1,67 @@ +part of '../home_collections.dart'; + +enum _ItemType { + ncAlbum, + album, + dirAlbum, + tagAlbum, +} + +@npLog +class _Item implements SelectableItemMetadata { + _Item(this.collection) { + if (collection.count != null) { + _subtitle = L10n.global().albumSize(collection.count!); + } + try { + _coverUrl = collection.getCoverUrl(k.coverSize, k.coverSize); + } catch (e, stackTrace) { + _log.warning("[_CollectionItem] Failed while getCoverUrl", e, stackTrace); + } + _initType(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _Item && collection.compareIdentity(other.collection)); + + @override + int get hashCode => collection.identityHashCode; + + @override + bool get isSelectable => true; + + String get name => collection.name; + + String? get subtitle => _subtitle; + + String? get coverUrl => _coverUrl; + + _ItemType get itemType => _itemType; + + void _initType() { + _ItemType? type; + if (collection.contentProvider is CollectionNcAlbumProvider) { + type = _ItemType.ncAlbum; + } else if (collection.contentProvider is CollectionAlbumProvider) { + final provider = collection.contentProvider as CollectionAlbumProvider; + if (provider.album.provider is AlbumStaticProvider) { + type = _ItemType.album; + } else if (provider.album.provider is AlbumDirProvider) { + type = _ItemType.dirAlbum; + } else if (provider.album.provider is AlbumTagProvider) { + type = _ItemType.tagAlbum; + } + } + if (type == null) { + throw UnsupportedError("Collection type not supported"); + } + _itemType = type; + } + + final Collection collection; + String? _subtitle; + String? _coverUrl; + late _ItemType _itemType; +} diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index 037ecc4c..1b644e07 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -37,9 +37,8 @@ import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/throttler.dart'; import 'package:nc_photos/use_case/startup_sync.dart'; -import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util; import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; +import 'package:nc_photos/widget/handler/add_selection_to_collection_handler.dart'; import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; import 'package:nc_photos/widget/handler/double_tap_exit_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; @@ -51,6 +50,7 @@ import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/settings.dart'; +import 'package:nc_photos/widget/smart_album_browser.dart'; import 'package:nc_photos/widget/viewer.dart'; import 'package:nc_photos/widget/zoom_menu_button.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -442,10 +442,8 @@ class _HomePhotosState extends State } Future _onSelectionAddToAlbumPressed(BuildContext context) { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( + return const AddSelectionToCollectionHandler()( context: context, - account: widget.account, selection: selectedListItems .whereType() .map((e) => e.file) @@ -1032,7 +1030,8 @@ class _SmartAlbumList extends StatelessWidget { : NetworkRectThumbnail.imageUrlForFile(account, coverFile), label: a.name, onTap: () { - album_browser_util.push(context, account, a); + Navigator.of(context).pushNamed(SmartAlbumBrowser.routeName, + arguments: SmartAlbumBrowserArguments(account, a)); }, ); }, diff --git a/app/lib/widget/home_search.dart b/app/lib/widget/home_search.dart index 4ac51563..76911191 100644 --- a/app/lib/widget/home_search.dart +++ b/app/lib/widget/home_search.dart @@ -25,7 +25,7 @@ import 'package:nc_photos/theme.dart'; import 'package:nc_photos/throttler.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; +import 'package:nc_photos/widget/handler/add_selection_to_collection_handler.dart'; import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/home_search_suggestion.dart'; @@ -455,10 +455,8 @@ class _HomeSearchState extends State } Future _onSelectionAddToAlbumPressed(BuildContext context) { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( + return const AddSelectionToCollectionHandler()( context: context, - account: widget.account, selection: selectedListItems .whereType() .map((e) => e.file) diff --git a/app/lib/widget/home_search_suggestion.dart b/app/lib/widget/home_search_suggestion.dart index 55f7b5b3..3ebfbb33 100644 --- a/app/lib/widget/home_search_suggestion.dart +++ b/app/lib/widget/home_search_suggestion.dart @@ -3,7 +3,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/bloc/home_search_suggestion.dart'; -import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/builder.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; @@ -11,11 +13,8 @@ import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; -import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util; +import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; -import 'package:nc_photos/widget/person_browser.dart'; -import 'package:nc_photos/widget/place_browser.dart'; -import 'package:nc_photos/widget/tag_browser.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; @@ -77,8 +76,10 @@ class _HomeSearchSuggestionState extends State } void _initBloc() { - _bloc = - (widget.controller._bloc ??= HomeSearchSuggestionBloc(widget.account)); + _bloc = (widget.controller._bloc ??= HomeSearchSuggestionBloc( + widget.account, + context.read().collectionsController, + )); if (_bloc.state is! HomeSearchSuggestionBlocInit) { // process the current state WidgetsBinding.instance.addPostFrameCallback((_) { @@ -124,39 +125,55 @@ class _HomeSearchSuggestionState extends State } } - void _onAlbumPressed(_AlbumListItem item) { + void _onCollectionPressed(_CollectionListItem item) { if (mounted) { - album_browser_util.push(context, widget.account, item.album); + Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(item.collection), + ); } } void _onTagPressed(_TagListItem item) { if (mounted) { - Navigator.of(context).pushNamed(TagBrowser.routeName, - arguments: TagBrowserArguments(widget.account, item.tag)); + Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byTags(widget.account, [item.tag]), + ), + ); } } void _onPersonPressed(_PersonListItem item) { if (mounted) { - Navigator.of(context).pushNamed(PersonBrowser.routeName, - arguments: PersonBrowserArguments(widget.account, item.person)); + Navigator.pushNamed( + context, + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byPerson(widget.account, item.person), + ), + ); } } void _onLocationPressed(_LocationListItem item) { if (mounted) { - Navigator.of(context).pushNamed(PlaceBrowser.routeName, - arguments: PlaceBrowserArguments( - widget.account, item.location.place, item.location.countryCode)); + Navigator.pushNamed( + context, + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byLocationGroup(widget.account, item.location), + ), + ); } } void _transformItems(List results) { final items = () sync* { for (final r in results) { - if (r is HomeSearchAlbumResult) { - yield _AlbumListItem(r.album, onTap: _onAlbumPressed); + if (r is HomeSearchCollectionResult) { + yield _CollectionListItem(r.collection, onTap: _onCollectionPressed); } else if (r is HomeSearchTagResult) { yield _TagListItem(r.tag, onTap: _onTagPressed); } else if (r is HomeSearchPersonResult) { @@ -181,21 +198,21 @@ abstract class _ListItem { Widget buildWidget(BuildContext context); } -class _AlbumListItem implements _ListItem { - const _AlbumListItem( - this.album, { +class _CollectionListItem implements _ListItem { + const _CollectionListItem( + this.collection, { this.onTap, }); @override - buildWidget(BuildContext context) => ListTile( + Widget buildWidget(BuildContext context) => ListTile( leading: const Icon(Icons.photo_album_outlined), - title: Text(album.name), + title: Text(collection.name), onTap: onTap == null ? null : () => onTap!(this), ); - final Album album; - final void Function(_AlbumListItem)? onTap; + final Collection collection; + final void Function(_CollectionListItem item)? onTap; } class _TagListItem implements _ListItem { diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 3b928cda..83ba0a8b 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -1,7 +1,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/language_util.dart' as language_util; import 'package:nc_photos/legacy/connect.dart' as legacy; @@ -10,23 +15,20 @@ import 'package:nc_photos/navigation_manager.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/widget/album_browser.dart'; import 'package:nc_photos/widget/album_dir_picker.dart'; import 'package:nc_photos/widget/album_importer.dart'; -import 'package:nc_photos/widget/album_picker.dart'; import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/changelog.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; +import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/connect.dart'; -import 'package:nc_photos/widget/dynamic_album_browser.dart'; import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_enhancer.dart'; import 'package:nc_photos/widget/local_file_viewer.dart'; import 'package:nc_photos/widget/people_browser.dart'; -import 'package:nc_photos/widget/person_browser.dart'; -import 'package:nc_photos/widget/place_browser.dart'; import 'package:nc_photos/widget/places_browser.dart'; import 'package:nc_photos/widget/result_viewer.dart'; import 'package:nc_photos/widget/root_picker.dart'; @@ -39,7 +41,6 @@ import 'package:nc_photos/widget/sign_in.dart'; import 'package:nc_photos/widget/slideshow_viewer.dart'; import 'package:nc_photos/widget/smart_album_browser.dart'; import 'package:nc_photos/widget/splash.dart'; -import 'package:nc_photos/widget/tag_browser.dart'; import 'package:nc_photos/widget/trashbin_browser.dart'; import 'package:nc_photos/widget/trashbin_viewer.dart'; import 'package:nc_photos/widget/viewer.dart'; @@ -47,13 +48,24 @@ import 'package:np_codegen/np_codegen.dart'; part 'my_app.g.dart'; -class MyApp extends StatefulWidget { - const MyApp({ - Key? key, - }) : super(key: key); +class MyApp extends StatelessWidget { + const MyApp({super.key}); @override - createState() => _MyAppState(); + Widget build(BuildContext context) { + final DiContainer _c = KiwiContainer().resolve(); + return MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) => AccountController(), + ), + RepositoryProvider( + create: (_) => PrefController(_c), + ), + ], + child: const _WrappedApp(), + ); + } static RouteObserver get routeObserver => _routeObserver; @@ -63,8 +75,15 @@ class MyApp extends StatefulWidget { static late BuildContext _globalContext; } +class _WrappedApp extends StatefulWidget { + const _WrappedApp(); + + @override + State createState() => _WrappedAppState(); +} + @npLog -class _MyAppState extends State +class _WrappedAppState extends State<_WrappedApp> implements SnackBarHandler, NavigationHandler { @override void initState() { @@ -139,11 +158,20 @@ class _MyAppState extends State @override getNavigator() => _navigatorKey.currentState; - Map _getRouter() => { - Setup.routeName: (context) => const Setup(), - SignIn.routeName: (context) => const SignIn(), - Splash.routeName: (context) => const Splash(), - legacy.SignIn.routeName: (_) => const legacy.SignIn(), + Map _getRouter() => { + Setup.routeName: () => MaterialPageRoute( + builder: (context) => const Setup(), + ), + SignIn.routeName: () => MaterialPageRoute( + builder: (context) => const SignIn(), + ), + Splash.routeName: () => MaterialPageRoute( + builder: (context) => const Splash(), + ), + legacy.SignIn.routeName: () => MaterialPageRoute( + builder: (context) => const legacy.SignIn(), + ), + CollectionPicker.routeName: CollectionPicker.buildRoute, }; Route? _onGenerateRoute(RouteSettings settings) { @@ -155,34 +183,29 @@ class _MyAppState extends State route ??= _handleConnectLegacyRoute(settings); route ??= _handleHomeRoute(settings); route ??= _handleRootPickerRoute(settings); - route ??= _handleAlbumBrowserRoute(settings); route ??= _handleSettingsRoute(settings); route ??= _handleArchiveBrowserRoute(settings); - route ??= _handleDynamicAlbumBrowserRoute(settings); route ??= _handleAlbumDirPickerRoute(settings); route ??= _handleAlbumImporterRoute(settings); route ??= _handleTrashbinBrowserRoute(settings); route ??= _handleTrashbinViewerRoute(settings); - route ??= _handlePersonBrowserRoute(settings); route ??= _handleSlideshowViewerRoute(settings); route ??= _handleSharingBrowserRoute(settings); route ??= _handleSharedFileViewerRoute(settings); route ??= _handleAlbumShareOutlierBrowserRoute(settings); route ??= _handleAccountSettingsRoute(settings); route ??= _handleShareFolderPickerRoute(settings); - route ??= _handleAlbumPickerRoute(settings); route ??= _handleSmartAlbumBrowserRoute(settings); route ??= _handleEnhancedPhotoBrowserRoute(settings); route ??= _handleLocalFileViewerRoute(settings); route ??= _handleEnhancementSettingsRoute(settings); route ??= _handleImageEditorRoute(settings); route ??= _handleChangelogRoute(settings); - route ??= _handleTagBrowserRoute(settings); route ??= _handlePeopleBrowserRoute(settings); - route ??= _handlePlaceBrowserRoute(settings); route ??= _handlePlacesBrowserRoute(settings); route ??= _handleResultViewerRoute(settings); route ??= _handleImageEnhancerRoute(settings); + route ??= _handleCollectionBrowserRoute(settings); return route; } @@ -197,9 +220,7 @@ class _MyAppState extends State Route? _handleBasicRoute(RouteSettings settings) { for (final e in _getRouter().entries) { if (e.key == settings.name) { - return MaterialPageRoute( - builder: e.value, - ); + return e.value(); } } return null; @@ -246,6 +267,8 @@ class _MyAppState extends State try { if (settings.name == Home.routeName && settings.arguments != null) { final args = settings.arguments as HomeArguments; + // move this elsewhere later + context.read().setCurrentAccount(args.account); return Home.buildRoute(args); } } catch (e) { @@ -266,19 +289,6 @@ class _MyAppState extends State return null; } - Route? _handleAlbumBrowserRoute(RouteSettings settings) { - try { - if (settings.name == AlbumBrowser.routeName && - settings.arguments != null) { - final args = settings.arguments as AlbumBrowserArguments; - return AlbumBrowser.buildRoute(args); - } - } catch (e) { - _log.severe("[_handleAlbumBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handleSettingsRoute(RouteSettings settings) { try { if (settings.name == Settings.routeName && settings.arguments != null) { @@ -305,20 +315,6 @@ class _MyAppState extends State return null; } - Route? _handleDynamicAlbumBrowserRoute(RouteSettings settings) { - try { - if (settings.name == DynamicAlbumBrowser.routeName && - settings.arguments != null) { - final args = settings.arguments as DynamicAlbumBrowserArguments; - return DynamicAlbumBrowser.buildRoute(args); - } - } catch (e) { - _log.severe( - "[_handleDynamicAlbumBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handleAlbumDirPickerRoute(RouteSettings settings) { try { if (settings.name == AlbumDirPicker.routeName && @@ -374,19 +370,6 @@ class _MyAppState extends State return null; } - Route? _handlePersonBrowserRoute(RouteSettings settings) { - try { - if (settings.name == PersonBrowser.routeName && - settings.arguments != null) { - final args = settings.arguments as PersonBrowserArguments; - return PersonBrowser.buildRoute(args); - } - } catch (e) { - _log.severe("[_handlePersonBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handleSlideshowViewerRoute(RouteSettings settings) { try { if (settings.name == SlideshowViewer.routeName && @@ -472,19 +455,6 @@ class _MyAppState extends State return null; } - Route? _handleAlbumPickerRoute(RouteSettings settings) { - try { - if (settings.name == AlbumPicker.routeName && - settings.arguments != null) { - final args = settings.arguments as AlbumPickerArguments; - return AlbumPicker.buildRoute(args); - } - } catch (e) { - _log.severe("[_handleAlbumPickerRoute] Failed while handling route", e); - } - return null; - } - Route? _handleSmartAlbumBrowserRoute(RouteSettings settings) { try { if (settings.name == SmartAlbumBrowser.routeName && @@ -574,18 +544,6 @@ class _MyAppState extends State return null; } - Route? _handleTagBrowserRoute(RouteSettings settings) { - try { - if (settings.name == TagBrowser.routeName && settings.arguments != null) { - final args = settings.arguments as TagBrowserArguments; - return TagBrowser.buildRoute(args); - } - } catch (e) { - _log.severe("[_handleTagBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handlePeopleBrowserRoute(RouteSettings settings) { try { if (settings.name == PeopleBrowser.routeName && @@ -599,19 +557,6 @@ class _MyAppState extends State return null; } - Route? _handlePlaceBrowserRoute(RouteSettings settings) { - try { - if (settings.name == PlaceBrowser.routeName && - settings.arguments != null) { - final args = settings.arguments as PlaceBrowserArguments; - return PlaceBrowser.buildRoute(args); - } - } catch (e) { - _log.severe("[_handlePlaceBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handlePlacesBrowserRoute(RouteSettings settings) { try { if (settings.name == PlacesBrowser.routeName && @@ -657,6 +602,20 @@ class _MyAppState extends State return null; } + Route? _handleCollectionBrowserRoute(RouteSettings settings) { + try { + if (settings.name == CollectionBrowser.routeName && + settings.arguments != null) { + final args = settings.arguments as CollectionBrowserArguments; + return CollectionBrowser.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleCollectionBrowserRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey(); diff --git a/app/lib/widget/my_app.g.dart b/app/lib/widget/my_app.g.dart index 00a51cd0..3a7d740c 100644 --- a/app/lib/widget/my_app.g.dart +++ b/app/lib/widget/my_app.g.dart @@ -6,9 +6,9 @@ part of 'my_app.dart'; // NpLogGenerator // ************************************************************************** -extension _$_MyAppStateNpLog on _MyAppState { +extension _$_WrappedAppStateNpLog on _WrappedAppState { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.my_app._MyAppState"); + static final log = Logger("widget.my_app._WrappedAppState"); } diff --git a/app/lib/widget/navigation_bar_blur_filter.dart b/app/lib/widget/navigation_bar_blur_filter.dart new file mode 100644 index 00000000..43159f08 --- /dev/null +++ b/app/lib/widget/navigation_bar_blur_filter.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:nc_photos/theme.dart'; + +class NavigationBarBlurFilter extends StatelessWidget { + const NavigationBarBlurFilter({ + super.key, + this.height, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: height ?? NavigationBarTheme.of(context).height, + child: ClipRect( + child: BackdropFilter( + filter: Theme.of(context).appBarBlurFilter, + child: const ColoredBox( + color: Colors.transparent, + ), + ), + ), + ); + } + + /// Height of the navigation bar. Use the value from the current theme if null + final double? height; +} diff --git a/app/lib/widget/new_album_dialog.dart b/app/lib/widget/new_album_dialog.dart deleted file mode 100644 index c2edfdbd..00000000 --- a/app/lib/widget/new_album_dialog.dart +++ /dev/null @@ -1,291 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/cover_provider.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/album/sort_provider.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/tag.dart'; -import 'package:nc_photos/exception_util.dart' as exception_util; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/use_case/create_album.dart'; -import 'package:nc_photos/widget/album_dir_picker.dart'; -import 'package:nc_photos/widget/processing_dialog.dart'; -import 'package:nc_photos/widget/tag_picker_dialog.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'new_album_dialog.g.dart'; - -/// Dialog to create a new album -/// -/// The created album will be popped to the previous route, or null if user -/// cancelled -class NewAlbumDialog extends StatefulWidget { - const NewAlbumDialog({ - Key? key, - required this.account, - this.isAllowDynamic = true, - }) : super(key: key); - - @override - createState() => _NewAlbumDialogState(); - - final Account account; - final bool isAllowDynamic; -} - -@npLog -class _NewAlbumDialogState extends State { - _NewAlbumDialogState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - _c = c; - } - - static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); - - @override - initState() { - super.initState(); - } - - @override - build(BuildContext context) { - return Visibility( - visible: _isVisible, - child: AlertDialog( - title: Text(L10n.global().createCollectionTooltip), - content: Form( - key: _formKey, - child: Container( - constraints: const BoxConstraints.tightFor(width: 280), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - decoration: InputDecoration( - hintText: L10n.global().nameInputHint, - ), - validator: (value) { - if (value!.isEmpty) { - return L10n.global().albumNameInputInvalidEmpty; - } - return null; - }, - onSaved: (value) { - _formValue.name = value!; - }, - ), - if (widget.isAllowDynamic) ...[ - DropdownButtonHideUnderline( - child: DropdownButtonFormField<_Provider>( - value: _provider, - items: _Provider.values - .map((e) => DropdownMenuItem<_Provider>( - value: e, - child: Text(e.toValueString(context)), - )) - .toList(), - onChanged: (newValue) { - setState(() { - _provider = newValue!; - }); - }, - onSaved: (value) { - _formValue.provider = value; - }, - ), - ), - const SizedBox(height: 8), - Text( - _provider.toDescription(context), - style: Theme.of(context).textTheme.bodyText2, - ), - ], - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => _onOkPressed(context), - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - ), - ); - } - - void _onOkPressed(BuildContext context) { - if (_formKey.currentState?.validate() == true) { - _formKey.currentState!.save(); - switch (_formValue.provider) { - case _Provider.static: - case null: - _onConfirmStaticAlbum(); - break; - case _Provider.dir: - _onConfirmDirAlbum(); - break; - case _Provider.tag: - _onConfirmTagAlbum(); - break; - } - } - } - - Future _onConfirmStaticAlbum() async { - setState(() { - _isVisible = false; - }); - unawaited(showDialog( - barrierDismissible: false, - context: context, - builder: (context) => - ProcessingDialog(text: L10n.global().genericProcessingDialogContent), - )); - try { - final album = Album( - name: _formValue.name, - provider: AlbumStaticProvider( - items: const [], - ), - coverProvider: AlbumAutoCoverProvider(), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - ); - _log.info("[_onConfirmStaticAlbum] Creating static album: $album"); - final newAlbum = await CreateAlbum(_c.albumRepo)(widget.account, album); - Navigator.of(context)..pop()..pop(newAlbum); - } catch (e, stacktrace) { - _log.shout("[_onConfirmStaticAlbum] Failed", e, stacktrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - Navigator.of(context)..pop()..pop(); - } - } - - Future _onConfirmDirAlbum() async { - setState(() { - _isVisible = false; - }); - try { - final dirs = await Navigator.of(context).pushNamed>( - AlbumDirPicker.routeName, - arguments: AlbumDirPickerArguments(widget.account)); - if (dirs == null) { - Navigator.of(context).pop(); - return; - } - final album = Album( - name: _formValue.name, - provider: AlbumDirProvider( - dirs: dirs, - ), - coverProvider: AlbumAutoCoverProvider(), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - ); - _log.info("[_onConfirmDirAlbum] Creating dir album: $album"); - final newAlbum = await CreateAlbum(_c.albumRepo)(widget.account, album); - Navigator.of(context).pop(newAlbum); - } catch (e, stacktrace) { - _log.shout("[_onConfirmDirAlbum] Failed", e, stacktrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - Navigator.of(context).pop(); - } - } - - Future _onConfirmTagAlbum() async { - setState(() { - _isVisible = false; - }); - try { - final tags = await showDialog>( - context: context, - builder: (_) => TagPickerDialog(account: widget.account), - ); - if (tags == null || tags.isEmpty) { - Navigator.of(context).pop(); - return; - } - final album = Album( - name: _formValue.name, - provider: AlbumTagProvider(tags: tags), - coverProvider: AlbumAutoCoverProvider(), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - ); - _log.info( - "[_onConfirmTagAlbum] Creating tag album: ${tags.map((t) => t.displayName).join(", ")}"); - final c = KiwiContainer().resolve(); - final newAlbum = await CreateAlbum(c.albumRepo)(widget.account, album); - Navigator.of(context).pop(newAlbum); - } catch (e, stackTrace) { - _log.shout("[_onConfirmTagAlbum] Failed", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - Navigator.of(context).pop(); - } - } - - late final DiContainer _c; - - final _formKey = GlobalKey(); - var _provider = _Provider.static; - - final _formValue = _FormValue(); - - var _isVisible = true; -} - -class _FormValue { - late String name; - _Provider? provider; -} - -enum _Provider { - static, - dir, - tag, -} - -extension on _Provider { - String toValueString(BuildContext context) { - switch (this) { - case _Provider.static: - return L10n.global().createCollectionDialogAlbumLabel; - case _Provider.dir: - return L10n.global().createCollectionDialogFolderLabel; - case _Provider.tag: - return L10n.global().createCollectionDialogTagLabel; - default: - throw StateError("Unknown value: $this"); - } - } - - String toDescription(BuildContext context) { - switch (this) { - case _Provider.static: - return L10n.global().createCollectionDialogAlbumDescription; - case _Provider.dir: - return L10n.global().createCollectionDialogFolderDescription; - case _Provider.tag: - return L10n.global().createCollectionDialogTagDescription; - default: - throw StateError("Unknown value: $this"); - } - } -} diff --git a/app/lib/widget/new_album_dialog.g.dart b/app/lib/widget/new_album_dialog.g.dart deleted file mode 100644 index e1b3a2aa..00000000 --- a/app/lib/widget/new_album_dialog.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'new_album_dialog.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_NewAlbumDialogStateNpLog on _NewAlbumDialogState { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.new_album_dialog._NewAlbumDialogState"); -} diff --git a/app/lib/widget/new_collection_dialog.dart b/app/lib/widget/new_collection_dialog.dart new file mode 100644 index 00000000..005c5418 --- /dev/null +++ b/app/lib/widget/new_collection_dialog.dart @@ -0,0 +1,280 @@ +import 'dart:async'; + +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/entity/tag.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/widget/album_dir_picker.dart'; +import 'package:nc_photos/widget/processing_dialog.dart'; +import 'package:nc_photos/widget/tag_picker_dialog.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'new_collection_dialog.g.dart'; +part 'new_collection_dialog/bloc.dart'; +part 'new_collection_dialog/state_event.dart'; + +/// Dialog to create a new collection +/// +/// Return the created collection, or null if user cancelled +class NewCollectionDialog extends StatelessWidget { + const NewCollectionDialog({ + super.key, + required this.account, + this.isAllowDynamic = true, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + account: account, + supportedProviders: { + _ProviderOption.appAlbum, + _ProviderOption.ncAlbum, + if (isAllowDynamic) ...{ + _ProviderOption.dir, + _ProviderOption.tag, + }, + }, + ), + child: const _WrappedNewCollectionDialog(), + ); + } + + final Account account; + final bool isAllowDynamic; +} + +class _WrappedNewCollectionDialog extends StatefulWidget { + const _WrappedNewCollectionDialog(); + + @override + State createState() => _WrappedNewCollectionDialogState(); +} + +@npLog +class _WrappedNewCollectionDialogState + extends State<_WrappedNewCollectionDialog> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.result != current.result && current.result != null, + listener: _onResult, + ), + ], + child: BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.result != current.result || + previous.showDialog != current.showDialog, + builder: (context, state) => Visibility( + visible: state.result == null && state.showDialog, + child: AlertDialog( + title: Text(L10n.global().createCollectionTooltip), + content: Form( + key: _formKey, + child: Container( + constraints: const BoxConstraints.tightFor(width: 280), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + _NameTextField(), + _ProviderDropdown(), + SizedBox(height: 8), + _ProviderDescription(), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => _onOkPressed(context), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ), + ), + ), + ); + } + + Future _onOkPressed(BuildContext context) async { + if (_formKey.currentState?.validate() == true) { + if (_bloc.state.formValue.provider == _ProviderOption.dir) { + _bloc.add(const _HideDialog()); + final dirs = await Navigator.of(context).pushNamed>( + AlbumDirPicker.routeName, + arguments: AlbumDirPickerArguments(_bloc.account), + ); + if (dirs == null) { + Navigator.of(context).pop(); + return; + } + _bloc + ..add(_SubmitDirs(dirs)) + ..add(const _SubmitForm()); + } else if (_bloc.state.formValue.provider == _ProviderOption.tag) { + _bloc.add(const _HideDialog()); + final tags = await showDialog>( + context: context, + builder: (_) => TagPickerDialog(account: _bloc.account), + ); + if (tags == null || tags.isEmpty) { + Navigator.of(context).pop(); + return; + } + _bloc + ..add(_SubmitTags(tags)) + ..add(const _SubmitForm()); + } else { + _bloc.add(const _SubmitForm()); + } + } + } + + Future _onResult(BuildContext context, _State state) async { + unawaited(showDialog( + barrierDismissible: false, + context: context, + builder: (_) => + ProcessingDialog(text: L10n.global().genericProcessingDialogContent), + )); + try { + final collection = await context + .read() + .collectionsController + .createNew(state.result!); + Navigator.of(context) + ..pop() + ..pop(collection); + } catch (e, stackTrace) { + _log.shout("[_onResult] Failed", e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(e)), + duration: k.snackBarDurationNormal, + )); + Navigator.of(context).pop(); + } + } + + late final _bloc = context.read<_Bloc>(); + + final _formKey = GlobalKey(); +} + +class _NameTextField extends StatelessWidget { + const _NameTextField(); + + @override + Widget build(BuildContext context) { + return TextFormField( + decoration: InputDecoration( + hintText: L10n.global().nameInputHint, + ), + validator: (value) { + if (value!.isEmpty) { + return L10n.global().albumNameInputInvalidEmpty; + } + return null; + }, + onChanged: (value) { + context.read<_Bloc>().add(_SubmitName(value)); + }, + ); + } +} + +class _ProviderDropdown extends StatelessWidget { + const _ProviderDropdown(); + + @override + Widget build(BuildContext context) { + return BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.formValue.provider != current.formValue.provider, + builder: (context, state) => DropdownButtonHideUnderline( + child: DropdownButtonFormField<_ProviderOption>( + value: state.formValue.provider, + items: state.supportedProviders + .map((e) => DropdownMenuItem<_ProviderOption>( + value: e, + child: Text(e.toValueString(context)), + )) + .toList(), + onChanged: (value) { + context.read<_Bloc>().add(_SubmitProvider(value!)); + }, + ), + ), + ); + } +} + +class _ProviderDescription extends StatelessWidget { + const _ProviderDescription(); + + @override + Widget build(BuildContext context) { + return BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.formValue.provider != current.formValue.provider, + builder: (context, state) => Text( + state.formValue.provider.toDescription(context), + style: Theme.of(context).textTheme.bodyText2, + ), + ); + } +} + +enum _ProviderOption { + appAlbum, + dir, + tag, + ncAlbum; + + String toValueString(BuildContext context) { + switch (this) { + case appAlbum: + return L10n.global().createCollectionDialogAlbumLabel; + case dir: + return L10n.global().createCollectionDialogFolderLabel; + case tag: + return L10n.global().createCollectionDialogTagLabel; + case ncAlbum: + return "Nextcloud Album"; + } + } + + String toDescription(BuildContext context) { + switch (this) { + case appAlbum: + return L10n.global().createCollectionDialogAlbumDescription; + case dir: + return L10n.global().createCollectionDialogFolderDescription; + case tag: + return L10n.global().createCollectionDialogTagDescription; + case ncAlbum: + return "Server-side album, require Nextcloud 25 or above"; + } + } +} diff --git a/app/lib/widget/new_collection_dialog.g.dart b/app/lib/widget/new_collection_dialog.g.dart new file mode 100644 index 00000000..3f38591d --- /dev/null +++ b/app/lib/widget/new_collection_dialog.g.dart @@ -0,0 +1,134 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'new_collection_dialog.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_FormValueCopyWithWorker { + _FormValue call( + {String? name, + _ProviderOption? provider, + List? dirs, + List? tags}); +} + +class _$_FormValueCopyWithWorkerImpl implements $_FormValueCopyWithWorker { + _$_FormValueCopyWithWorkerImpl(this.that); + + @override + _FormValue call( + {dynamic name, dynamic provider, dynamic dirs, dynamic tags}) { + return _FormValue( + name: name as String? ?? that.name, + provider: provider as _ProviderOption? ?? that.provider, + dirs: dirs as List? ?? that.dirs, + tags: tags as List? ?? that.tags); + } + + final _FormValue that; +} + +extension $_FormValueCopyWith on _FormValue { + $_FormValueCopyWithWorker get copyWith => _$copyWith; + $_FormValueCopyWithWorker get _$copyWith => + _$_FormValueCopyWithWorkerImpl(this); +} + +abstract class $_StateCopyWithWorker { + _State call({_FormValue? formValue, Collection? result, bool? showDialog}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic formValue, dynamic result = copyWithNull, dynamic showDialog}) { + return _State( + supportedProviders: that.supportedProviders, + formValue: formValue as _FormValue? ?? that.formValue, + result: result == copyWithNull ? that.result : result as Collection?, + showDialog: showDialog as bool? ?? that.showDialog); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedNewCollectionDialogStateNpLog + on _WrappedNewCollectionDialogState { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.new_collection_dialog._WrappedNewCollectionDialogState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.new_collection_dialog._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_SubmitNameToString on _SubmitName { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitName {value: $value}"; + } +} + +extension _$_SubmitProviderToString on _SubmitProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitProvider {value: ${value.name}}"; + } +} + +extension _$_SubmitDirsToString on _SubmitDirs { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitDirs {value: [length: ${value.length}]}"; + } +} + +extension _$_SubmitTagsToString on _SubmitTags { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitTags {value: [length: ${value.length}]}"; + } +} + +extension _$_SubmitFormToString on _SubmitForm { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitForm {}"; + } +} + +extension _$_HideDialogToString on _HideDialog { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_HideDialog {}"; + } +} diff --git a/app/lib/widget/new_collection_dialog/bloc.dart b/app/lib/widget/new_collection_dialog/bloc.dart new file mode 100644 index 00000000..b16ff069 --- /dev/null +++ b/app/lib/widget/new_collection_dialog/bloc.dart @@ -0,0 +1,115 @@ +part of '../new_collection_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> { + _Bloc({ + required this.account, + required Set<_ProviderOption> supportedProviders, + }) : super(_State.init( + supportedProviders: supportedProviders, + )) { + on<_FormEvent>(_onFormEvent); + on<_HideDialog>(_onHideDialog); + } + + void _onFormEvent(_FormEvent ev, Emitter<_State> emit) { + _log.info("$ev"); + if (ev is _SubmitName) { + _onSubmitName(ev, emit); + } else if (ev is _SubmitProvider) { + _onSubmitProvider(ev, emit); + } else if (ev is _SubmitDirs) { + _onSubmitDirs(ev, emit); + } else if (ev is _SubmitTags) { + _onSubmitTags(ev, emit); + } else if (ev is _SubmitForm) { + _onSubmitForm(ev, emit); + } + } + + void _onHideDialog(_HideDialog ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(showDialog: false)); + } + + void _onSubmitName(_SubmitName ev, Emitter<_State> emit) { + emit(state.copyWith( + formValue: state.formValue.copyWith(name: ev.value), + )); + } + + void _onSubmitProvider(_SubmitProvider ev, Emitter<_State> emit) { + emit(state.copyWith( + formValue: state.formValue.copyWith(provider: ev.value), + )); + } + + void _onSubmitDirs(_SubmitDirs ev, Emitter<_State> emit) { + emit(state.copyWith( + formValue: state.formValue.copyWith(dirs: ev.value), + )); + } + + void _onSubmitTags(_SubmitTags ev, Emitter<_State> emit) { + emit(state.copyWith( + formValue: state.formValue.copyWith(tags: ev.value), + )); + } + + void _onSubmitForm(_SubmitForm ev, Emitter<_State> emit) { + emit(state.copyWith( + result: Collection( + name: state.formValue.name, + contentProvider: _buildProvider(), + ), + )); + } + + CollectionContentProvider _buildProvider() { + switch (state.formValue.provider) { + case _ProviderOption.appAlbum: + return CollectionAlbumProvider( + account: account, + album: Album( + name: state.formValue.name, + provider: AlbumStaticProvider(items: const []), + coverProvider: AlbumAutoCoverProvider(), + sortProvider: const AlbumTimeSortProvider(isAscending: false), + ), + ); + + case _ProviderOption.dir: + return CollectionAlbumProvider( + account: account, + album: Album( + name: state.formValue.name, + provider: AlbumDirProvider(dirs: state.formValue.dirs), + coverProvider: AlbumAutoCoverProvider(), + sortProvider: const AlbumTimeSortProvider(isAscending: false), + ), + ); + + case _ProviderOption.tag: + return CollectionAlbumProvider( + account: account, + album: Album( + name: state.formValue.name, + provider: AlbumTagProvider(tags: state.formValue.tags), + coverProvider: AlbumAutoCoverProvider(), + sortProvider: const AlbumTimeSortProvider(isAscending: false), + ), + ); + + case _ProviderOption.ncAlbum: + return CollectionNcAlbumProvider( + account: account, + album: NcAlbum.createNew( + account: account, + name: state.formValue.name, + ), + ); + } + } + + final Account account; +} diff --git a/app/lib/widget/new_collection_dialog/state_event.dart b/app/lib/widget/new_collection_dialog/state_event.dart new file mode 100644 index 00000000..f5c664b8 --- /dev/null +++ b/app/lib/widget/new_collection_dialog/state_event.dart @@ -0,0 +1,107 @@ +part of '../new_collection_dialog.dart'; + +@genCopyWith +class _FormValue { + const _FormValue({ + this.name = "", + this.provider = _ProviderOption.appAlbum, + this.dirs = const [], + this.tags = const [], + }); + + final String name; + final _ProviderOption provider; + final List dirs; + final List tags; +} + +@genCopyWith +class _State { + const _State({ + required this.supportedProviders, + required this.formValue, + this.result, + required this.showDialog, + }); + + factory _State.init({ + required Set<_ProviderOption> supportedProviders, + }) { + return _State( + supportedProviders: supportedProviders, + formValue: const _FormValue(), + showDialog: true, + ); + } + + @keep + final Set<_ProviderOption> supportedProviders; + + final _FormValue formValue; + final Collection? result; + final bool showDialog; +} + +abstract class _Event { + const _Event(); +} + +abstract class _FormEvent implements _Event { + const _FormEvent(); +} + +@toString +class _SubmitName extends _FormEvent { + const _SubmitName(this.value); + + @override + String toString() => _$toString(); + + final String value; +} + +@toString +class _SubmitProvider extends _FormEvent { + const _SubmitProvider(this.value); + + @override + String toString() => _$toString(); + + final _ProviderOption value; +} + +@toString +class _SubmitDirs extends _FormEvent { + const _SubmitDirs(this.value); + + @override + String toString() => _$toString(); + + final List value; +} + +@toString +class _SubmitTags extends _FormEvent { + const _SubmitTags(this.value); + + @override + String toString() => _$toString(); + + final List value; +} + +@toString +class _SubmitForm extends _FormEvent { + const _SubmitForm(); + + @override + String toString() => _$toString(); +} + +@toString +class _HideDialog extends _Event { + const _HideDialog(); + + @override + String toString() => _$toString(); +} diff --git a/app/lib/widget/people_browser.dart b/app/lib/widget/people_browser.dart index 96c351b6..9224d8e8 100644 --- a/app/lib/widget/people_browser.dart +++ b/app/lib/widget/people_browser.dart @@ -9,14 +9,15 @@ import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/list_person.dart'; import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection/builder.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/collection_list_item.dart'; -import 'package:nc_photos/widget/person_browser.dart'; import 'package:np_codegen/np_codegen.dart'; part 'people_browser.g.dart'; @@ -152,8 +153,13 @@ class _PeopleBrowserState extends State { } void _onItemTap(Person person) { - Navigator.pushNamed(context, PersonBrowser.routeName, - arguments: PersonBrowserArguments(widget.account, person)); + Navigator.pushNamed( + context, + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byPerson(widget.account, person), + ), + ); } void _transformItems(List items) { diff --git a/app/lib/widget/person_browser.dart b/app/lib/widget/person_browser.dart deleted file mode 100644 index a92af8ee..00000000 --- a/app/lib/widget/person_browser.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/bloc/list_face_file.dart'; -import 'package:nc_photos/compute_queue.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/download_handler.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/entity/person.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/exception_util.dart' as exception_util; -import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/language_util.dart' as language_util; -import 'package:nc_photos/object_extension.dart'; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/share_handler.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/widget/app_bar_title_container.dart'; -import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; -import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; -import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; -import 'package:nc_photos/widget/network_thumbnail.dart'; -import 'package:nc_photos/widget/photo_list_item.dart'; -import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; -import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; -import 'package:nc_photos/widget/selection_app_bar.dart'; -import 'package:nc_photos/widget/viewer.dart'; -import 'package:nc_photos/widget/zoom_menu_button.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'person_browser.g.dart'; - -class PersonBrowserArguments { - PersonBrowserArguments(this.account, this.person); - - final Account account; - final Person person; -} - -/// Show a list of all faces associated with this person -class PersonBrowser extends StatefulWidget { - static const routeName = "/person-browser"; - - static Route buildRoute(PersonBrowserArguments args) => MaterialPageRoute( - builder: (context) => PersonBrowser.fromArgs(args), - ); - - const PersonBrowser({ - Key? key, - required this.account, - required this.person, - }) : super(key: key); - - PersonBrowser.fromArgs(PersonBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - person: args.person, - ); - - @override - createState() => _PersonBrowserState(); - - final Account account; - final Person person; -} - -@npLog -class _PersonBrowserState extends State - with SelectableItemStreamListMixin { - _PersonBrowserState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - assert(ListFaceFileBloc.require(c)); - _c = c; - } - - static bool require(DiContainer c) => true; - - @override - initState() { - super.initState(); - _initBloc(); - _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); - - _filePropertyUpdatedListener.begin(); - } - - @override - dispose() { - _filePropertyUpdatedListener.end(); - super.dispose(); - } - - @override - build(BuildContext context) { - return Scaffold( - body: BlocListener( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), - ), - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as()?.run((fileItem) { - Navigator.pushNamed( - context, - Viewer.routeName, - arguments: - ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), - ); - }); - } - - void _initBloc() { - _log.info("[_initBloc] Initialize bloc"); - _reqQuery(); - } - - Widget _buildContent(BuildContext context, ListFaceFileBlocState state) { - return buildItemStreamListOuter( - context, - child: CustomScrollView( - slivers: [ - _buildAppBar(context, state), - if (state is ListFaceFileBlocLoading || _buildItemQueue.isProcessing) - const SliverToBoxAdapter( - child: Align( - alignment: Alignment.center, - child: LinearProgressIndicator(), - ), - ), - buildItemStreamList( - maxCrossAxisExtent: _thumbSize.toDouble(), - ), - ], - ), - ); - } - - Widget _buildAppBar(BuildContext context, ListFaceFileBlocState state) { - if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return _buildNormalAppBar(context, state); - } - } - - Widget _buildNormalAppBar(BuildContext context, ListFaceFileBlocState state) { - return SliverAppBar( - floating: true, - titleSpacing: 0, - title: AppBarTitleContainer( - icon: _buildFaceImage(context), - title: Text(widget.person.name), - subtitle: - Text(L10n.global().personPhotoCountText(_backingFiles.length)), - ), - actions: [ - ZoomMenuButton( - initialZoom: _thumbZoomLevel, - minZoom: 0, - maxZoom: 2, - onZoomChanged: (value) { - setState(() { - _thumbZoomLevel = value.round(); - }); - Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel); - }, - ), - ], - ); - } - - Widget _buildFaceImage(BuildContext context) { - Widget cover; - Widget buildPlaceholder() => Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.person, - color: Theme.of(context).listPlaceholderForegroundColor, - ), - ); - try { - cover = NetworkRectThumbnail( - account: widget.account, - imageUrl: api_util.getFacePreviewUrl( - widget.account, - widget.person.thumbFaceId, - size: k.faceThumbSize, - ), - errorBuilder: (_) => buildPlaceholder(), - ); - } catch (_) { - cover = FittedBox( - child: buildPlaceholder(), - ); - } - - return ClipRRect( - borderRadius: BorderRadius.circular(64), - child: Container( - color: Theme.of(context).listPlaceholderBackgroundColor, - constraints: const BoxConstraints.expand(), - child: cover, - ), - ); - } - - Widget _buildSelectionAppBar(BuildContext context) { - return SelectionAppBar( - count: selectedListItems.length, - onClosePressed: () { - setState(() { - clearSelectedItems(); - }); - }, - actions: [ - IconButton( - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - onPressed: () { - _onSelectionSharePressed(context); - }, - ), - IconButton( - icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, - onPressed: () { - _onSelectionAddToAlbumPressed(context); - }, - ), - PopupMenuButton<_SelectionMenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - PopupMenuItem( - value: _SelectionMenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.archive, - child: Text(L10n.global().archiveTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.delete, - child: Text(L10n.global().deleteTooltip), - ), - ], - onSelected: (option) { - _onSelectionMenuSelected(context, option); - }, - ), - ], - ); - } - - void _onStateChange(BuildContext context, ListFaceFileBlocState state) { - if (state is ListFaceFileBlocInit) { - itemStreamListItems = []; - } else if (state is ListFaceFileBlocSuccess || - state is ListFaceFileBlocLoading) { - _transformItems(state.items); - } else if (state is ListFaceFileBlocFailure) { - _transformItems(state.items); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } else if (state is ListFaceFileBlocInconsistent) { - _reqQuery(); - } - } - - void _onSelectionMenuSelected( - BuildContext context, _SelectionMenuOption option) { - switch (option) { - case _SelectionMenuOption.archive: - _onSelectionArchivePressed(context); - break; - case _SelectionMenuOption.delete: - _onSelectionDeletePressed(context); - break; - case _SelectionMenuOption.download: - _onSelectionDownloadPressed(); - break; - default: - _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); - break; - } - } - - void _onSelectionSharePressed(BuildContext context) { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - ShareHandler( - c, - context: context, - clearSelection: () { - setState(() { - clearSelectedItems(); - }); - }, - ).shareFiles(widget.account, selected); - } - - Future _onSelectionAddToAlbumPressed(BuildContext context) { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( - context: context, - account: widget.account, - selection: selectedListItems - .whereType() - .map((e) => e.file) - .toList(), - clearSelection: () { - if (mounted) { - setState(() { - clearSelectedItems(); - }); - } - }, - ); - } - - void _onSelectionDownloadPressed() { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - DownloadHandler(c).downloadFiles(widget.account, selected); - setState(() { - clearSelectedItems(); - }); - } - - Future _onSelectionArchivePressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - final selectedFiles = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - setState(() { - clearSelectedItems(); - }); - await ArchiveSelectionHandler(c)( - account: widget.account, - selection: selectedFiles, - ); - } - - Future _onSelectionDeletePressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - final selectedFiles = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - setState(() { - clearSelectedItems(); - }); - await RemoveSelectionHandler(c)( - account: widget.account, - selection: selectedFiles, - isMoveToTrash: true, - ); - } - - void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) { - if (_backingFiles.containsIf(ev.file, (a, b) => a.fdId == b.fdId) != true) { - return; - } - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - - Future _transformItems(List files) async { - final PhotoListItemSorter? sorter; - final PhotoListItemGrouper? grouper; - if (Pref().isPhotosTabSortByNameOr()) { - sorter = photoListFilenameSorter; - grouper = null; - } else { - sorter = photoListFileDateTimeSorter; - grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0); - } - - _buildItemQueue.addJob( - PhotoListItemBuilderArguments( - widget.account, - files, - sorter: sorter, - grouper: grouper, - shouldShowFavoriteBadge: true, - locale: language_util.getSelectedLocale() ?? - PlatformDispatcher.instance.locale, - ), - buildPhotoListItem, - (result) { - if (mounted) { - setState(() { - _backingFiles = result.backingFiles; - itemStreamListItems = result.listItems; - }); - } - }, - ); - } - - void _reqQuery() { - _bloc.add(ListFaceFileBlocQuery(widget.account, widget.person)); - } - - late final DiContainer _c; - - late final ListFaceFileBloc _bloc = ListFaceFileBloc(_c); - var _backingFiles = []; - - final _buildItemQueue = - ComputeQueue(); - - var _thumbZoomLevel = 0; - int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); - - late final Throttler _refreshThrottler = Throttler( - onTriggered: (_) { - if (mounted) { - _transformItems(_bloc.state.items); - } - }, - logTag: "_PersonBrowserState.refresh", - ); - - late final _filePropertyUpdatedListener = - AppEventListener(_onFilePropertyUpdated); -} - -enum _SelectionMenuOption { - archive, - delete, - download, -} diff --git a/app/lib/widget/person_browser.g.dart b/app/lib/widget/person_browser.g.dart deleted file mode 100644 index a769fdf1..00000000 --- a/app/lib/widget/person_browser.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'person_browser.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_PersonBrowserStateNpLog on _PersonBrowserState { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.person_browser._PersonBrowserState"); -} diff --git a/app/lib/widget/photo_list_item.dart b/app/lib/widget/photo_list_item.dart index 1b25a5fb..a1287887 100644 --- a/app/lib/widget/photo_list_item.dart +++ b/app/lib/widget/photo_list_item.dart @@ -344,10 +344,10 @@ class PhotoListLabel extends StatelessWidget { build(BuildContext context) { return Container( alignment: AlignmentDirectional.centerStart, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( text, - style: Theme.of(context).textTheme.subtitle1, + style: Theme.of(context).textTheme.labelLarge, maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/app/lib/widget/place_browser.dart b/app/lib/widget/place_browser.dart deleted file mode 100644 index 0d9b434f..00000000 --- a/app/lib/widget/place_browser.dart +++ /dev/null @@ -1,411 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/bloc/list_location_file.dart'; -import 'package:nc_photos/compute_queue.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/download_handler.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/exception_util.dart' as exception_util; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/language_util.dart' as language_util; -import 'package:nc_photos/location_util.dart' as location_util; -import 'package:nc_photos/object_extension.dart'; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/share_handler.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/widget/about_geocoding_dialog.dart'; -import 'package:nc_photos/widget/app_bar_title_container.dart'; -import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; -import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; -import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; -import 'package:nc_photos/widget/photo_list_item.dart'; -import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; -import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; -import 'package:nc_photos/widget/selection_app_bar.dart'; -import 'package:nc_photos/widget/viewer.dart'; -import 'package:nc_photos/widget/zoom_menu_button.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'place_browser.g.dart'; - -class PlaceBrowserArguments { - const PlaceBrowserArguments(this.account, this.place, this.countryCode); - - final Account account; - final String? place; - final String countryCode; -} - -class PlaceBrowser extends StatefulWidget { - static const routeName = "/place-browser"; - - static Route buildRoute(PlaceBrowserArguments args) => MaterialPageRoute( - builder: (context) => PlaceBrowser.fromArgs(args), - ); - - const PlaceBrowser({ - Key? key, - required this.account, - required this.place, - required this.countryCode, - }) : super(key: key); - - PlaceBrowser.fromArgs(PlaceBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - place: args.place, - countryCode: args.countryCode, - ); - - @override - createState() => _PlaceBrowserState(); - - final Account account; - final String? place; - final String countryCode; -} - -@npLog -class _PlaceBrowserState extends State - with SelectableItemStreamListMixin { - _PlaceBrowserState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - assert(ListLocationFileBloc.require(c)); - _c = c; - } - - static bool require(DiContainer c) => true; - - @override - initState() { - super.initState(); - _initBloc(); - _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); - } - - @override - build(BuildContext context) { - return Scaffold( - body: BlocListener( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), - ), - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as()?.run((fileItem) { - Navigator.pushNamed( - context, - Viewer.routeName, - arguments: - ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), - ); - }); - } - - void _initBloc() { - _log.info("[_initBloc] Initialize bloc"); - _reqQuery(); - } - - Widget _buildContent(BuildContext context, ListLocationFileBlocState state) { - return buildItemStreamListOuter( - context, - child: CustomScrollView( - slivers: [ - _buildAppBar(context, state), - if (state is ListLocationFileBlocLoading || - _buildItemQueue.isProcessing) - const SliverToBoxAdapter( - child: Align( - alignment: Alignment.center, - child: LinearProgressIndicator(), - ), - ), - buildItemStreamList( - maxCrossAxisExtent: _thumbSize.toDouble(), - ), - ], - ), - ); - } - - Widget _buildAppBar(BuildContext context, ListLocationFileBlocState state) { - if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return _buildNormalAppBar(context, state); - } - } - - Widget _buildNormalAppBar( - BuildContext context, ListLocationFileBlocState state) { - return SliverAppBar( - floating: true, - titleSpacing: 0, - title: AppBarTitleContainer( - title: Text( - widget.place ?? location_util.alpha2CodeToName(widget.countryCode)!, - ), - subtitle: (state is! ListLocationFileBlocLoading && - !_buildItemQueue.isProcessing) - ? Text(L10n.global().personPhotoCountText(_backingFiles.length)) - : null, - ), - actions: [ - ZoomMenuButton( - initialZoom: _thumbZoomLevel, - minZoom: 0, - maxZoom: 2, - onZoomChanged: (value) { - setState(() { - _thumbZoomLevel = value.round(); - }); - Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel); - }, - ), - IconButton( - onPressed: () { - showDialog( - context: context, - builder: (_) => const AboutGeocodingDialog(), - ); - }, - icon: const Icon(Icons.info_outline), - ), - ], - ); - } - - Widget _buildSelectionAppBar(BuildContext context) { - return SelectionAppBar( - count: selectedListItems.length, - onClosePressed: () { - setState(() { - clearSelectedItems(); - }); - }, - actions: [ - IconButton( - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - onPressed: () { - _onSelectionSharePressed(context); - }, - ), - IconButton( - icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, - onPressed: () { - _onSelectionAddToAlbumPressed(context); - }, - ), - PopupMenuButton<_SelectionMenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - PopupMenuItem( - value: _SelectionMenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.archive, - child: Text(L10n.global().archiveTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.delete, - child: Text(L10n.global().deleteTooltip), - ), - ], - onSelected: (option) { - _onSelectionMenuSelected(context, option); - }, - ), - ], - ); - } - - void _onStateChange(BuildContext context, ListLocationFileBlocState state) { - if (state is ListLocationFileBlocInit) { - itemStreamListItems = []; - } else if (state is ListLocationFileBlocSuccess || - state is ListLocationFileBlocLoading) { - _transformItems(state.items); - } else if (state is ListLocationFileBlocFailure) { - _transformItems(state.items); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } else if (state is ListLocationFileBlocInconsistent) { - _reqQuery(); - } - } - - void _onSelectionMenuSelected( - BuildContext context, _SelectionMenuOption option) { - switch (option) { - case _SelectionMenuOption.archive: - _onSelectionArchivePressed(context); - break; - case _SelectionMenuOption.delete: - _onSelectionDeletePressed(context); - break; - case _SelectionMenuOption.download: - _onSelectionDownloadPressed(); - break; - default: - _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); - break; - } - } - - void _onSelectionSharePressed(BuildContext context) { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - ShareHandler( - c, - context: context, - clearSelection: () { - setState(() { - clearSelectedItems(); - }); - }, - ).shareFiles(widget.account, selected); - } - - Future _onSelectionAddToAlbumPressed(BuildContext context) { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( - context: context, - account: widget.account, - selection: selectedListItems - .whereType() - .map((e) => e.file) - .toList(), - clearSelection: () { - if (mounted) { - setState(() { - clearSelectedItems(); - }); - } - }, - ); - } - - void _onSelectionDownloadPressed() { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - DownloadHandler(c).downloadFiles(widget.account, selected); - setState(() { - clearSelectedItems(); - }); - } - - Future _onSelectionArchivePressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - final selectedFiles = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - setState(() { - clearSelectedItems(); - }); - await ArchiveSelectionHandler(c)( - account: widget.account, - selection: selectedFiles, - ); - } - - Future _onSelectionDeletePressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - final selectedFiles = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - setState(() { - clearSelectedItems(); - }); - await RemoveSelectionHandler(c)( - account: widget.account, - selection: selectedFiles, - isMoveToTrash: true, - ); - } - - Future _transformItems(List files) async { - final PhotoListItemSorter? sorter; - final PhotoListItemGrouper? grouper; - if (Pref().isPhotosTabSortByNameOr()) { - sorter = photoListFilenameSorter; - grouper = null; - } else { - sorter = photoListFileDateTimeSorter; - grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0); - } - - _buildItemQueue.addJob( - PhotoListItemBuilderArguments( - widget.account, - files, - sorter: sorter, - grouper: grouper, - shouldShowFavoriteBadge: true, - locale: language_util.getSelectedLocale() ?? - PlatformDispatcher.instance.locale, - ), - buildPhotoListItem, - (result) { - if (mounted) { - setState(() { - _backingFiles = result.backingFiles; - itemStreamListItems = result.listItems; - }); - } - }, - ); - } - - void _reqQuery() { - _bloc.add(ListLocationFileBlocQuery( - widget.account, widget.place, widget.countryCode)); - } - - late final DiContainer _c; - - late final ListLocationFileBloc _bloc = ListLocationFileBloc(_c); - var _backingFiles = []; - - final _buildItemQueue = - ComputeQueue(); - - var _thumbZoomLevel = 0; - int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); -} - -enum _SelectionMenuOption { - archive, - delete, - download, -} diff --git a/app/lib/widget/place_browser.g.dart b/app/lib/widget/place_browser.g.dart deleted file mode 100644 index eee6b2ca..00000000 --- a/app/lib/widget/place_browser.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'place_browser.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_PlaceBrowserStateNpLog on _PlaceBrowserState { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.place_browser._PlaceBrowserState"); -} diff --git a/app/lib/widget/places_browser.dart b/app/lib/widget/places_browser.dart index fe2767ae..d6647a63 100644 --- a/app/lib/widget/places_browser.dart +++ b/app/lib/widget/places_browser.dart @@ -8,6 +8,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/list_location.dart'; import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection/builder.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; @@ -15,9 +16,9 @@ import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; import 'package:nc_photos/widget/about_geocoding_dialog.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/collection_list_item.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; -import 'package:nc_photos/widget/place_browser.dart'; import 'package:np_codegen/np_codegen.dart'; part 'places_browser.g.dart'; @@ -175,16 +176,14 @@ class _PlacesBrowserState extends State { } } - void _onPlaceTap(LocationGroup location) { - Navigator.pushNamed(context, PlaceBrowser.routeName, - arguments: PlaceBrowserArguments( - widget.account, location.place, location.countryCode)); - } - - void _onCountryTap(LocationGroup location) { - Navigator.pushNamed(context, PlaceBrowser.routeName, - arguments: - PlaceBrowserArguments(widget.account, null, location.countryCode)); + void _onLocationTap(LocationGroup location) { + Navigator.pushNamed( + context, + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byLocationGroup(widget.account, location), + ), + ); } void _transformItems(LocationGroupResult? result) { @@ -210,7 +209,7 @@ class _PlacesBrowserState extends State { place: e.place, thumbUrl: NetworkRectThumbnail.imageUrlForFileId( widget.account, e.latestFileId), - onTap: () => _onPlaceTap(e), + onTap: () => _onLocationTap(e), )) .toList(); _countryItems = result.countryCode @@ -220,7 +219,7 @@ class _PlacesBrowserState extends State { country: e.place, thumbUrl: NetworkRectThumbnail.imageUrlForFileId( widget.account, e.latestFileId), - onTap: () => _onCountryTap(e), + onTap: () => _onLocationTap(e), )) .toList(); } diff --git a/app/lib/widget/search_landing.dart b/app/lib/widget/search_landing.dart index bd71410c..f7a5c69a 100644 --- a/app/lib/widget/search_landing.dart +++ b/app/lib/widget/search_landing.dart @@ -8,6 +8,7 @@ import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/search_landing.dart'; import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection/builder.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; @@ -18,10 +19,9 @@ import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/people_browser.dart'; -import 'package:nc_photos/widget/person_browser.dart'; -import 'package:nc_photos/widget/place_browser.dart'; import 'package:nc_photos/widget/places_browser.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -225,15 +225,21 @@ class _SearchLandingState extends State { } void _onPersonItemTap(Person person) { - Navigator.pushNamed(context, PersonBrowser.routeName, - arguments: PersonBrowserArguments(widget.account, person)); + Navigator.pushNamed( + context, + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byPerson(widget.account, person), + ), + ); } void _onLocationItemTap(LocationGroup location) { Navigator.of(context).pushNamed( - PlaceBrowser.routeName, - arguments: PlaceBrowserArguments( - widget.account, location.place, location.countryCode), + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byLocationGroup(widget.account, location), + ), ); } diff --git a/app/lib/widget/selectable_item_list.dart b/app/lib/widget/selectable_item_list.dart new file mode 100644 index 00000000..05413984 --- /dev/null +++ b/app/lib/widget/selectable_item_list.dart @@ -0,0 +1,225 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos/session_storage.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/widget/selectable.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'selectable_item_list.g.dart'; + +/// Describe an item in a [SelectableItemList] +/// +/// Derived classes should implement [operator ==] in order for the list to +/// correctly map the items after changing the list content +abstract class SelectableItemMetadata { + bool get isSelectable; +} + +class SelectableItemList + extends StatefulWidget { + const SelectableItemList({ + super.key, + required this.items, + this.selectedItems = const {}, + required this.maxCrossAxisExtent, + required this.itemBuilder, + required this.staggeredTileBuilder, + this.childBorderRadius, + this.indicatorAlignment = Alignment.topLeft, + this.onItemTap, + this.onSelectionChange, + }); + + @override + State createState() => _SelectableItemListState(); + + final List items; + final Set selectedItems; + final double maxCrossAxisExtent; + // why are these dynamic instead of T? Because dart is stupid... + final Widget Function(BuildContext context, int index, T metadata) + itemBuilder; + final StaggeredTile? Function(int index, T metadata) staggeredTileBuilder; + final BorderRadius? childBorderRadius; + final Alignment indicatorAlignment; + + final void Function(BuildContext context, int index, T metadata)? onItemTap; + final void Function(BuildContext context, Set selected)? onSelectionChange; +} + +@npLog +class _SelectableItemListState + extends State> { + @override + void initState() { + super.initState(); + _keyboardFocus.requestFocus(); + } + + @override + void didUpdateWidget(covariant SelectableItemList oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.items == oldWidget.items) { + return; + } + _remapSelected(); + } + + @override + Widget build(BuildContext context) { + if (platform_k.isWeb) { + // support shift+click group selection on web + return RawKeyboardListener( + onKey: (ev) { + _isKeyboardRangeSelecting = ev.isShiftPressed; + }, + focusNode: _keyboardFocus, + child: _buildBody(context), + ); + } else { + return _buildBody(context); + } + } + + Widget _buildBody(BuildContext context) { + return SliverStaggeredGrid.extentBuilder( + key: ObjectKey(widget.maxCrossAxisExtent), + maxCrossAxisExtent: widget.maxCrossAxisExtent, + itemCount: widget.items.length, + itemBuilder: (context, i) { + final meta = widget.items[i]; + if (meta.isSelectable) { + return Selectable( + isSelected: widget.selectedItems.contains(meta), + iconSize: 32, + childBorderRadius: + widget.childBorderRadius ?? BorderRadius.circular(24), + indicatorAlignment: widget.indicatorAlignment, + onTap: _isSelecting + ? () => _onItemSelect(context, i, meta) + : () => _onItemTap(context, i, meta), + onLongPress: () => _onItemLongPress(i, meta), + child: widget.itemBuilder(context, i, meta), + ); + } else { + return widget.itemBuilder(context, i, meta); + } + }, + staggeredTileBuilder: (i) => + widget.staggeredTileBuilder(i, widget.items[i]), + ); + } + + void _onItemTap(BuildContext context, int index, T metadata) { + widget.onItemTap?.call(context, index, metadata); + } + + void _onItemSelect(BuildContext context, int index, T metadata) { + if (!widget.items.containsIdentical(metadata)) { + _log.warning("[_onItemSelect] Item not found in backing list, ignoring"); + return; + } + final newSelectedItems = Set.of(widget.selectedItems); + if (widget.selectedItems.contains(metadata)) { + // unselect + setState(() { + newSelectedItems.remove(metadata); + _lastSelectPosition = null; + }); + } else { + if (_isKeyboardRangeSelecting && _lastSelectPosition != null) { + setState(() { + _selectRange(newSelectedItems, _lastSelectPosition!, index); + _lastSelectPosition = index; + }); + } else { + setState(() { + // select single + newSelectedItems.add(metadata); + _lastSelectPosition = index; + }); + } + } + widget.onSelectionChange?.call(context, newSelectedItems); + } + + void _onItemLongPress(int index, T metadata) { + if (!widget.items.containsIdentical(metadata)) { + _log.warning( + "[_onItemLongPress] Item not found in backing list, ignoring"); + return; + } + final wasSelecting = _isSelecting; + final newSelectedItems = Set.of(widget.selectedItems); + if (_isSelecting && _lastSelectPosition != null) { + setState(() { + _selectRange(newSelectedItems, _lastSelectPosition!, index); + _lastSelectPosition = index; + }); + } else { + setState(() { + // select single + newSelectedItems.add(metadata); + _lastSelectPosition = index; + }); + } + widget.onSelectionChange?.call(context, newSelectedItems); + + // show notification on first entry to selection mode in each session + if (!wasSelecting) { + if (!SessionStorage().hasShowRangeSelectNotification) { + SnackBarManager().showSnackBar( + SnackBar( + content: Text(platform_k.isWeb + ? L10n.global().webSelectRangeNotification + : L10n.global().mobileSelectRangeNotification), + duration: k.snackBarDurationNormal, + ), + canBeReplaced: true, + ); + SessionStorage().hasShowRangeSelectNotification = true; + } + } + } + + /// Select items between two indexes [a] and [b] in [target] list + /// + /// [a] and [b] are not necessary to be sorted, this method will handle both + /// [a] > [b] and [a] < [b] cases + void _selectRange(Set target, int a, int b) { + final beg = math.min(a, b); + final end = math.max(a, b) + 1; + target.addAll(widget.items.sublist(beg, end).where((e) => e.isSelectable)); + } + + /// Remap selected items to the new item list, typically called after content + /// of the list was changed + void _remapSelected() { + _log.info( + "[_remapSelected] Mapping ${widget.selectedItems.length} items to new list"); + final newSelected = widget.selectedItems + .map((from) => widget.items.firstWhereOrNull((to) => from == to)) + .whereNotNull() + .toSet(); + if (newSelected.length != widget.selectedItems.length) { + _log.warning( + "[_remapSelected] ${widget.selectedItems.length - newSelected.length} items not found in the new list"); + } + widget.onSelectionChange?.call(context, newSelected); + } + + bool get _isSelecting => widget.selectedItems.isNotEmpty; + + // keyboard support + final _keyboardFocus = FocusNode(); + int? _lastSelectPosition; + bool _isKeyboardRangeSelecting = false; +} diff --git a/app/lib/widget/selectable_item_list.g.dart b/app/lib/widget/selectable_item_list.g.dart new file mode 100644 index 00000000..3059c09f --- /dev/null +++ b/app/lib/widget/selectable_item_list.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'selectable_item_list.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_SelectableItemListStateNpLog on _SelectableItemListState { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.selectable_item_list._SelectableItemListState"); +} diff --git a/app/lib/widget/smart_album_browser.dart b/app/lib/widget/smart_album_browser.dart index c7bb7e6d..4c071b63 100644 --- a/app/lib/widget/smart_album_browser.dart +++ b/app/lib/widget/smart_album_browser.dart @@ -17,7 +17,7 @@ import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/widget/album_browser_mixin.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; +import 'package:nc_photos/widget/handler/add_selection_to_collection_handler.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; @@ -249,10 +249,8 @@ class _SmartAlbumBrowserState extends State } Future _onSelectionAddPressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( + return const AddSelectionToCollectionHandler()( context: context, - account: widget.account, selection: selectedListItems .whereType<_FileListItem>() .map((e) => e.file) diff --git a/app/lib/widget/tag_browser.dart b/app/lib/widget/tag_browser.dart deleted file mode 100644 index 64ab4357..00000000 --- a/app/lib/widget/tag_browser.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/bloc/list_tag_file.dart'; -import 'package:nc_photos/compute_queue.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/download_handler.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/entity/tag.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/exception_util.dart' as exception_util; -import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/language_util.dart' as language_util; -import 'package:nc_photos/object_extension.dart'; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/share_handler.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/widget/app_bar_title_container.dart'; -import 'package:nc_photos/widget/builder/photo_list_item_builder.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; -import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; -import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; -import 'package:nc_photos/widget/photo_list_item.dart'; -import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; -import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; -import 'package:nc_photos/widget/selection_app_bar.dart'; -import 'package:nc_photos/widget/viewer.dart'; -import 'package:nc_photos/widget/zoom_menu_button.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'tag_browser.g.dart'; - -class TagBrowserArguments { - TagBrowserArguments(this.account, this.tag); - - final Account account; - final Tag tag; -} - -class TagBrowser extends StatefulWidget { - static const routeName = "/tag-browser"; - - static Route buildRoute(TagBrowserArguments args) => MaterialPageRoute( - builder: (context) => TagBrowser.fromArgs(args), - ); - - const TagBrowser({ - Key? key, - required this.account, - required this.tag, - }) : super(key: key); - - TagBrowser.fromArgs(TagBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - tag: args.tag, - ); - - @override - createState() => _TagBrowserState(); - - final Account account; - final Tag tag; -} - -@npLog -class _TagBrowserState extends State - with SelectableItemStreamListMixin { - _TagBrowserState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - _c = c; - } - - static bool require(DiContainer c) => true; - - @override - initState() { - super.initState(); - _initBloc(); - _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); - - _filePropertyUpdatedListener.begin(); - } - - @override - dispose() { - _filePropertyUpdatedListener.end(); - super.dispose(); - } - - @override - build(BuildContext context) { - return Scaffold( - body: BlocListener( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), - ), - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as()?.run((fileItem) { - Navigator.pushNamed( - context, - Viewer.routeName, - arguments: - ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex), - ); - }); - } - - void _initBloc() { - _log.info("[_initBloc] Initialize bloc"); - _reqQuery(); - } - - Widget _buildContent(BuildContext context, ListTagFileBlocState state) { - return buildItemStreamListOuter( - context, - child: CustomScrollView( - slivers: [ - _buildAppBar(context, state), - if (state is ListTagFileBlocLoading || _buildItemQueue.isProcessing) - const SliverToBoxAdapter( - child: Align( - alignment: Alignment.center, - child: LinearProgressIndicator(), - ), - ), - buildItemStreamList( - maxCrossAxisExtent: _thumbSize.toDouble(), - ), - ], - ), - ); - } - - Widget _buildAppBar(BuildContext context, ListTagFileBlocState state) { - if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return _buildNormalAppBar(context, state); - } - } - - Widget _buildNormalAppBar(BuildContext context, ListTagFileBlocState state) { - return SliverAppBar( - floating: true, - titleSpacing: 0, - title: AppBarTitleContainer( - icon: const Icon(Icons.local_offer_outlined), - title: Text(widget.tag.displayName), - subtitle: - (state is! ListTagFileBlocLoading && !_buildItemQueue.isProcessing) - ? Text(L10n.global().personPhotoCountText(_backingFiles.length)) - : null, - ), - actions: [ - ZoomMenuButton( - initialZoom: _thumbZoomLevel, - minZoom: 0, - maxZoom: 2, - onZoomChanged: (value) { - setState(() { - _thumbZoomLevel = value.round(); - }); - Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel); - }, - ), - ], - ); - } - - Widget _buildSelectionAppBar(BuildContext context) { - return SelectionAppBar( - count: selectedListItems.length, - onClosePressed: () { - setState(() { - clearSelectedItems(); - }); - }, - actions: [ - IconButton( - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - onPressed: () { - _onSelectionSharePressed(context); - }, - ), - IconButton( - icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, - onPressed: () { - _onSelectionAddToAlbumPressed(context); - }, - ), - PopupMenuButton<_SelectionMenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - PopupMenuItem( - value: _SelectionMenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.archive, - child: Text(L10n.global().archiveTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.delete, - child: Text(L10n.global().deleteTooltip), - ), - ], - onSelected: (option) { - _onSelectionMenuSelected(context, option); - }, - ), - ], - ); - } - - void _onStateChange(BuildContext context, ListTagFileBlocState state) { - if (state is ListTagFileBlocInit) { - itemStreamListItems = []; - } else if (state is ListTagFileBlocSuccess || - state is ListTagFileBlocLoading) { - _transformItems(state.items); - } else if (state is ListTagFileBlocFailure) { - _transformItems(state.items); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } else if (state is ListTagFileBlocInconsistent) { - _reqQuery(); - } - } - - void _onSelectionMenuSelected( - BuildContext context, _SelectionMenuOption option) { - switch (option) { - case _SelectionMenuOption.archive: - _onSelectionArchivePressed(context); - break; - case _SelectionMenuOption.delete: - _onSelectionDeletePressed(context); - break; - case _SelectionMenuOption.download: - _onSelectionDownloadPressed(); - break; - default: - _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); - break; - } - } - - void _onSelectionSharePressed(BuildContext context) { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - ShareHandler( - c, - context: context, - clearSelection: () { - setState(() { - clearSelectedItems(); - }); - }, - ).shareFiles(widget.account, selected); - } - - Future _onSelectionAddToAlbumPressed(BuildContext context) { - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( - context: context, - account: widget.account, - selection: selectedListItems - .whereType() - .map((e) => e.file) - .toList(), - clearSelection: () { - if (mounted) { - setState(() { - clearSelectedItems(); - }); - } - }, - ); - } - - void _onSelectionDownloadPressed() { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - DownloadHandler(c).downloadFiles(widget.account, selected); - setState(() { - clearSelectedItems(); - }); - } - - Future _onSelectionArchivePressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - final selectedFiles = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - setState(() { - clearSelectedItems(); - }); - await ArchiveSelectionHandler(c)( - account: widget.account, - selection: selectedFiles, - ); - } - - Future _onSelectionDeletePressed(BuildContext context) async { - final c = KiwiContainer().resolve(); - final selectedFiles = selectedListItems - .whereType() - .map((e) => e.file) - .toList(); - setState(() { - clearSelectedItems(); - }); - await RemoveSelectionHandler(c)( - account: widget.account, - selection: selectedFiles, - isMoveToTrash: true, - ); - } - - void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) { - if (_backingFiles.containsIf(ev.file, (a, b) => a.fdId == b.fdId) != true) { - return; - } - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); - } - - Future _transformItems(List files) async { - final PhotoListItemSorter? sorter; - final PhotoListItemGrouper? grouper; - if (Pref().isPhotosTabSortByNameOr()) { - sorter = photoListFilenameSorter; - grouper = null; - } else { - sorter = photoListFileDateTimeSorter; - grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0); - } - - _buildItemQueue.addJob( - PhotoListItemBuilderArguments( - widget.account, - files, - sorter: sorter, - grouper: grouper, - shouldShowFavoriteBadge: true, - locale: language_util.getSelectedLocale() ?? - PlatformDispatcher.instance.locale, - ), - buildPhotoListItem, - (result) { - if (mounted) { - setState(() { - _backingFiles = result.backingFiles; - itemStreamListItems = result.listItems; - }); - } - }, - ); - } - - void _reqQuery() { - _bloc.add(ListTagFileBlocQuery(widget.account, widget.tag)); - } - - late final DiContainer _c; - - late final ListTagFileBloc _bloc = ListTagFileBloc(_c); - var _backingFiles = []; - - final _buildItemQueue = - ComputeQueue(); - - var _thumbZoomLevel = 0; - int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); - - late final Throttler _refreshThrottler = Throttler( - onTriggered: (_) { - if (mounted) { - _transformItems(_bloc.state.items); - } - }, - logTag: "_TagBrowserState.refresh", - ); - - late final _filePropertyUpdatedListener = - AppEventListener(_onFilePropertyUpdated); -} - -enum _SelectionMenuOption { - archive, - delete, - download, -} diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 1abd289d..9c1b02bf 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -23,8 +23,8 @@ import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/use_case/album/remove_from_album.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; -import 'package:nc_photos/use_case/remove_from_album.dart'; import 'package:nc_photos/use_case/update_property.dart'; import 'package:nc_photos/widget/disposable.dart'; import 'package:nc_photos/widget/handler/archive_selection_handler.dart'; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index 7dfa0cc8..f5f9867c 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -32,7 +32,7 @@ import 'package:nc_photos/use_case/update_property.dart'; import 'package:nc_photos/widget/about_geocoding_dialog.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; import 'package:nc_photos/widget/gps_map.dart'; -import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart'; +import 'package:nc_photos/widget/handler/add_selection_to_collection_handler.dart'; import 'package:nc_photos/widget/list_tile_center_leading.dart'; import 'package:nc_photos/widget/photo_date_time_edit_dialog.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -410,10 +410,8 @@ class _ViewerDetailPaneState extends State { Future _onAddToAlbumPressed(BuildContext context) { assert(_file != null); - final c = KiwiContainer().resolve(); - return AddSelectionToAlbumHandler(c)( + return const AddSelectionToCollectionHandler()( context: context, - account: widget.account, selection: [_file!], clearSelection: () {}, ); diff --git a/app/pubspec.lock b/app/pubspec.lock index 27e873ae..f63a2ef3 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -31,6 +31,13 @@ packages: url: "https://gitlab.com/nc-photos/plus_plugins" source: git version: "3.1.1" + ansicolor: + dependency: transitive + description: + name: ansicolor + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" args: dependency: transitive description: @@ -336,6 +343,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.17.2" + dart_code_metrics: + dependency: "direct dev" + description: + name: dart_code_metrics + url: "https://pub.dartlang.org" + source: hosted + version: "4.19.1" dart_style: dependency: transitive description: @@ -1055,12 +1069,12 @@ packages: source: hosted version: "4.0.0" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.27.4" + version: "0.27.7" screen_brightness: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d6a9d46b..d67c0b90 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -103,6 +103,7 @@ dependencies: path: ^1.8.0 path_provider: ^2.0.6 quiver: ^3.1.0 + rxdart: ^0.27.7 screen_brightness: ^0.2.1 shared_preferences: ^2.0.8 smooth_corner: ^1.1.0 @@ -138,6 +139,7 @@ dev_dependencies: url: https://gitlab.com/nkming2/dart-copy-with path: copy_with_build ref: copy_with_build-1.6.0 + dart_code_metrics: 4.19.1 drift_dev: ^1.7.0 flutter_lints: ^2.0.1 flutter_test: diff --git a/app/test/entity/album/sort_provider_test.dart b/app/test/entity/album/sort_provider_test.dart deleted file mode 100644 index e13c9c13..00000000 --- a/app/test/entity/album/sort_provider_test.dart +++ /dev/null @@ -1,439 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/album/sort_provider.dart'; -import 'package:np_common/ci_string.dart'; -import 'package:test/test.dart'; - -import '../../test_util.dart' as util; - -void main() { - group("AlbumSortProvider", () { - group("fromJson", () { - test("AlbumTimeSortProvider", _timeFromJson); - }); - group("toJson", () { - test("AlbumTimeSortProvider", _timeToJson); - }); - }); - - group("AlbumTimeSortProvider", () { - group("AlbumFileItem", () { - test("ascending", _timeFileAscending); - test("descending", _timeFileDescending); - }); - group("w/ non AlbumFileItem", () { - test("ascending", _timeNonFileAscending); - test("descending", _timeNonFileDescending); - test("head", _timeNonFileHead); - }); - }); - - group("AlbumFilenameSortProvider", () { - group("AlbumFileItem", () { - test("ascending", _filenameFileAscending); - test("descending", _filenameFileDescending); - test("natural", _filenameFileNatural); - }); - group("w/ non AlbumFileItem", () { - test("ascending", _filenameNonFileAscending); - test("descending", _filenameNonFileDescending); - test("head", _filenameNonFileHead); - }); - }); -} - -void _timeFromJson() { - final json = { - "type": "time", - "content": { - "isAscending": false, - }, - }; - expect( - AlbumSortProvider.fromJson(json), - const AlbumTimeSortProvider(isAscending: false), - ); -} - -void _timeToJson() { - expect( - const AlbumTimeSortProvider(isAscending: false).toJson(), - { - "type": "time", - "content": { - "isAscending": false, - }, - }, - ); -} - -/// Sort files by time -/// -/// Expect: items sorted -void _timeFileAscending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - const sort = AlbumTimeSortProvider(isAscending: true); - expect(sort.sort(items), [items[1], items[0], items[2]]); -} - -/// Sort files by time, descending -/// -/// Expect: items sorted -void _timeFileDescending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - const sort = AlbumTimeSortProvider(isAscending: false); - expect(sort.sort(items), [items[2], items[0], items[1]]); -} - -/// Sort files + non files by time -/// -/// Expect: file sorted, non file stick with the prev file -void _timeNonFileAscending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - items.insert( - 2, - AlbumLabelItem( - addedBy: CiString("admin"), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - text: "test", - ), - ); - const sort = AlbumTimeSortProvider(isAscending: true); - expect(sort.sort(items), [items[1], items[2], items[0], items[3]]); -} - -/// Sort files + non files by time, descending -/// -/// Expect: file sorted, non file stick with the prev file -void _timeNonFileDescending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - items.insert( - 2, - AlbumLabelItem( - addedBy: CiString("admin"), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - text: "test", - ), - ); - const sort = AlbumTimeSortProvider(isAscending: false); - expect(sort.sort(items), [items[3], items[0], items[1], items[2]]); -} - -/// Sort files + non files by time, with the head being a non file -/// -/// Expect: file sorted, non file stick at the head -void _timeNonFileHead() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - items.insert( - 0, - AlbumLabelItem( - addedBy: CiString("admin"), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - text: "test", - ), - ); - const sort = AlbumTimeSortProvider(isAscending: true); - expect(sort.sort(items), [items[0], items[2], items[1], items[3]]); -} - -/// Sort files by filename -/// -/// Expect: items sorted -void _filenameFileAscending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - const sort = AlbumFilenameSortProvider(isAscending: true); - expect(sort.sort(items), [items[1], items[2], items[0]]); -} - -/// Sort files by filename, descending -/// -/// Expect: items sorted -void _filenameFileDescending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - const sort = AlbumFilenameSortProvider(isAscending: false); - expect(sort.sort(items), [items[0], items[2], items[1]]); -} - -/// Sort files by filename -/// -/// Expect: items sorted in natural order -void _filenameFileNatural() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test033_2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test033_1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test033_3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test11.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - ) - ..addJpeg( - "admin/test2_999.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - const sort = AlbumFilenameSortProvider(isAscending: true); - expect( - sort.sort(items), - [items[4], items[5], items[3], items[1], items[0], items[2]], - ); -} - -/// Sort files + non files by filename -/// -/// Expect: file sorted, non file stick with the prev file -void _filenameNonFileAscending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - items.insert( - 2, - AlbumLabelItem( - addedBy: CiString("admin"), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - text: "test", - ), - ); - const sort = AlbumFilenameSortProvider(isAscending: true); - expect(sort.sort(items), [items[1], items[2], items[3], items[0]]); -} - -/// Sort files + non files by filename, descending -/// -/// Expect: file sorted, non file stick with the prev file -void _filenameNonFileDescending() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - items.insert( - 2, - AlbumLabelItem( - addedBy: CiString("admin"), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - text: "test", - ), - ); - const sort = AlbumFilenameSortProvider(isAscending: false); - expect(sort.sort(items), [items[0], items[3], items[1], items[2]]); -} - -/// Sort files + non files by filename, with the head being a non file -/// -/// Expect: file sorted, non file stick at the head -void _filenameNonFileHead() { - final items = (util.FilesBuilder() - ..addJpeg( - "admin/test3.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), - ) - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), - ) - ..addJpeg( - "admin/test2.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), - )) - .build() - .mapIndexed((i, f) => AlbumFileItem( - addedBy: CiString("admin"), - addedAt: f.lastModified!, - file: f, - )) - .toList(); - items.insert( - 0, - AlbumLabelItem( - addedBy: CiString("admin"), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - text: "test", - ), - ); - const sort = AlbumFilenameSortProvider(isAscending: true); - expect(sort.sort(items), [items[0], items[2], items[3], items[1]]); -} diff --git a/app/test/entity/sqlite/database_test.dart b/app/test/entity/sqlite/database_test.dart index e4d70411..0a81c9b7 100644 --- a/app/test/entity/sqlite/database_test.dart +++ b/app/test/entity/sqlite/database_test.dart @@ -320,6 +320,8 @@ Future _truncate() async { "album_shares", "tags", "persons", + "nc_albums", + "nc_album_items", }); for (final t in tables) { expect( diff --git a/app/test/mock_type.dart b/app/test/mock_type.dart index 75f0f318..83915faa 100644 --- a/app/test/mock_type.dart +++ b/app/test/mock_type.dart @@ -128,7 +128,7 @@ class MockFavoriteMemoryRepo extends MockFavoriteRepo { /// Mock of [FileDataSource] where all methods will throw UnimplementedError class MockFileDataSource implements FileDataSource { @override - Future copy(Account account, File f, String destination, + Future copy(Account account, FileDescriptor f, String destination, {bool? shouldOverwrite}) { throw UnimplementedError(); } @@ -170,7 +170,7 @@ class MockFileDataSource implements FileDataSource { } @override - Future remove(Account account, File f) { + Future remove(Account account, FileDescriptor f) { throw UnimplementedError(); } @@ -211,9 +211,9 @@ class MockFileMemoryDataSource extends MockFileDataSource { } @override - remove(Account account, File file) async { + remove(Account account, FileDescriptor file) async { files.removeWhere((f) { - if (file.isCollection == true) { + if ((file as File).isCollection == true) { return file_util.isOrUnderDir(f, file); } else { return f.compareServerIdentity(file); @@ -230,7 +230,8 @@ class MockFileWebdavDataSource implements FileWebdavDataSource { const MockFileWebdavDataSource(this.src); @override - copy(Account account, File f, String destination, {bool? shouldOverwrite}) => + copy(Account account, FileDescriptor f, String destination, + {bool? shouldOverwrite}) => src.copy(account, f, destination, shouldOverwrite: shouldOverwrite); @override @@ -264,7 +265,7 @@ class MockFileWebdavDataSource implements FileWebdavDataSource { src.putBinary(account, path, content); @override - remove(Account account, File f) => src.remove(account, f); + remove(Account account, FileDescriptor f) => src.remove(account, f); @override updateProperty( diff --git a/app/test/use_case/add_file_to_album_test.dart b/app/test/use_case/add_file_to_album_test.dart new file mode 100644 index 00000000..60453206 --- /dev/null +++ b/app/test/use_case/add_file_to_album_test.dart @@ -0,0 +1,375 @@ +import 'package:clock/clock.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/use_case/album/add_file_to_album.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:test/test.dart'; + +import '../mock_type.dart'; +import '../test_util.dart' as util; + +void main() { + KiwiContainer().registerInstance(MockEventBus()); + + group("AddFileToAlbum", () { + test("file", _addFile); + test("ignore existing file", _addExistingFile); + test("ignore existing file (shared)", _addExistingSharedFile); + group("shared album (owned)", () { + test("file", _addFileToSharedAlbumOwned); + test("file owned by user", _addFileOwnedByUserToSharedAlbumOwned); + }); + group("shared album (not owned)", () { + test("file", _addFileToMultiuserSharedAlbumNotOwned); + }); + }); +} + +/// Add a [File] to an [Album] +/// +/// Expect: file added to album +Future _addFile() async { + await withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () async { + final account = util.buildAccount(); + final file = (util.FilesBuilder(initialFileId: 1) + ..addJpeg("admin/test1.jpg")) + .build()[0]; + final album = util.AlbumBuilder().build(); + final albumFile = album.albumFile!; + final c = DiContainer( + fileRepo: MockFileMemoryRepo(), + albumRepo: MockAlbumMemoryRepo([album]), + shareRepo: MockShareRepo(), + sqliteDb: util.buildTestDb(), + pref: Pref.scoped(PrefMemoryProvider()), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); + + await AddFileToAlbum(c)( + account, + c.albumMemoryRepo.findAlbumByPath(albumFile.path), + [file], + ); + expect( + c.albumMemoryRepo.albums, + [ + Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test", + provider: AlbumStaticProvider( + items: [ + AlbumFileItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), + file: file, + ).minimize(), + ], + latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5), + ), + coverProvider: AlbumAutoCoverProvider( + coverFile: file, + ), + sortProvider: const AlbumNullSortProvider(), + albumFile: albumFile, + ), + ], + ); + }); +} + +/// Add a [File], already included in the [Album], to an [Album] +/// +/// Expect: file not added to album +Future _addExistingFile() async { + await withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () async { + final account = util.buildAccount(); + final files = (util.FilesBuilder(initialFileId: 1) + ..addJpeg( + "admin/test1.jpg", + lastModified: DateTime.utc(2019, 1, 2, 3, 4, 5), + )) + .build(); + final album = (util.AlbumBuilder() + ..addFileItem( + files[0], + addedAt: clock.now().toUtc(), + )) + .build(); + final oldFile = files[0]; + final newFile = files[0].copyWith(); + final albumFile = album.albumFile!; + final c = DiContainer( + fileRepo: MockFileMemoryRepo(), + albumRepo: MockAlbumMemoryRepo([album]), + shareRepo: MockShareRepo(), + sqliteDb: util.buildTestDb(), + pref: Pref.scoped(PrefMemoryProvider()), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); + + await AddFileToAlbum(c)( + account, + c.albumMemoryRepo.findAlbumByPath(albumFile.path), + [newFile], + ); + expect( + c.albumMemoryRepo.albums, + [ + Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test", + provider: AlbumStaticProvider( + items: [ + AlbumFileItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), + file: files[0], + ), + ], + latestItemTime: DateTime.utc(2019, 1, 2, 3, 4, 5), + ), + coverProvider: AlbumAutoCoverProvider(coverFile: files[0]), + sortProvider: const AlbumNullSortProvider(), + albumFile: albumFile, + ), + ], + ); + // when there's a conflict, it's guaranteed that the original file in the + // album is kept and the incoming file dropped + expect( + identical( + AlbumStaticProvider.of(c.albumMemoryRepo.albums[0]) + .items + .whereType() + .first + .file, + oldFile), + true); + expect( + identical( + AlbumStaticProvider.of(c.albumMemoryRepo.albums[0]) + .items + .whereType() + .first + .file, + newFile), + false); + }); +} + +/// Add a file shared with you to an album, where the file is already included +/// +/// Expect: file not added to album +Future _addExistingSharedFile() async { + await withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () async { + final account = util.buildAccount(); + final user1Account = util.buildAccount(userId: "user1"); + final files = (util.FilesBuilder(initialFileId: 1) + ..addJpeg("admin/test1.jpg")) + .build(); + final user1Files = [ + files[0].copyWith(path: "remote.php/dav/files/user1/test1.jpg") + ]; + final album = (util.AlbumBuilder()..addFileItem(files[0])).build(); + final albumFile = album.albumFile!; + final c = DiContainer( + fileRepo: MockFileMemoryRepo(), + albumRepo: MockAlbumMemoryRepo([album]), + shareRepo: MockShareRepo(), + sqliteDb: util.buildTestDb(), + pref: Pref.scoped(PrefMemoryProvider()), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); + + await AddFileToAlbum(c)( + account, + c.albumMemoryRepo.findAlbumByPath(albumFile.path), + [user1Files[0]], + ); + expect( + c.albumMemoryRepo.albums, + [ + Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test", + provider: AlbumStaticProvider( + items: [ + AlbumFileItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), + file: files[0], + ), + ], + latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5), + ), + coverProvider: AlbumAutoCoverProvider(coverFile: files[0]), + sortProvider: const AlbumNullSortProvider(), + albumFile: albumFile, + ), + ], + ); + }); +} + +/// Add a file to a shared album (admin -> user1) +/// +/// Expect: a new share (admin -> user1) is created for the file +Future _addFileToSharedAlbumOwned() async { + await withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () async { + final account = util.buildAccount(); + final file = (util.FilesBuilder(initialFileId: 1) + ..addJpeg("admin/test1.jpg")) + .build()[0]; + final album = (util.AlbumBuilder()..addShare("user1")).build(); + final albumFile = album.albumFile!; + final c = DiContainer( + fileRepo: MockFileMemoryRepo(), + albumRepo: MockAlbumMemoryRepo([album]), + shareRepo: MockShareMemoryRepo([ + util.buildShare(id: "0", file: albumFile, shareWith: "user1"), + ]), + sqliteDb: util.buildTestDb(), + pref: Pref.scoped(PrefMemoryProvider({ + "isLabEnableSharedAlbum": true, + })), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); + + await AddFileToAlbum(c)( + account, + c.albumMemoryRepo.findAlbumByPath(albumFile.path), + [file], + ); + expect( + c.shareMemoryRepo.shares, + [ + util.buildShare(id: "0", file: albumFile, shareWith: "user1"), + util.buildShare(id: "1", file: file, shareWith: "user1"), + ], + ); + }); +} + +/// Add a file owned by user (user1) to a shared album (admin -> user1) +/// +/// Expect: no shares created +Future _addFileOwnedByUserToSharedAlbumOwned() async { + await withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () async { + final account = util.buildAccount(); + final file = (util.FilesBuilder(initialFileId: 1) + ..addJpeg("admin/test1.jpg", ownerId: "user1")) + .build()[0]; + final album = (util.AlbumBuilder()..addShare("user1")).build(); + final albumFile = album.albumFile!; + final c = DiContainer( + fileRepo: MockFileMemoryRepo(), + albumRepo: MockAlbumMemoryRepo([album]), + shareRepo: MockShareMemoryRepo([ + util.buildShare(id: "0", file: albumFile, shareWith: "user1"), + ]), + sqliteDb: util.buildTestDb(), + pref: Pref.scoped(PrefMemoryProvider({ + "isLabEnableSharedAlbum": true, + })), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); + + await AddFileToAlbum(c)( + account, + c.albumMemoryRepo.findAlbumByPath(albumFile.path), + [file], + ); + expect( + c.shareMemoryRepo.shares, + [ + util.buildShare(id: "0", file: albumFile, shareWith: "user1"), + ], + ); + }); +} + +/// Add a file to a shared album (user1 -> admin, user2) +/// +/// Expect: a new share (admin -> user1, user2) is created for the file +Future _addFileToMultiuserSharedAlbumNotOwned() async { + await withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () async { + // doesn't work right now, skipped + final account = util.buildAccount(); + final file = (util.FilesBuilder(initialFileId: 1) + ..addJpeg("admin/test1.jpg")) + .build()[0]; + final album = (util.AlbumBuilder(ownerId: "user1") + ..addShare("admin") + ..addShare("user2")) + .build(); + final albumFile = album.albumFile!; + final c = DiContainer( + fileRepo: MockFileMemoryRepo(), + albumRepo: MockAlbumMemoryRepo([album]), + shareRepo: MockShareMemoryRepo([ + util.buildShare( + id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"), + util.buildShare( + id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"), + ]), + sqliteDb: util.buildTestDb(), + pref: Pref.scoped(PrefMemoryProvider({ + "isLabEnableSharedAlbum": true, + })), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); + + await AddFileToAlbum(c)( + account, + c.albumMemoryRepo.findAlbumByPath(albumFile.path), + [file], + ); + expect( + c.shareMemoryRepo.shares, + [ + util.buildShare( + id: "0", uidOwner: "user1", file: albumFile, shareWith: "admin"), + util.buildShare( + id: "1", uidOwner: "user1", file: albumFile, shareWith: "user2"), + // the order for these two shares are actually NOT guaranteed + util.buildShare(id: "2", file: file, shareWith: "user2"), + util.buildShare(id: "3", file: file, shareWith: "user1"), + ], + ); + }); +} diff --git a/app/test/use_case/add_to_album_test.dart b/app/test/use_case/add_to_album_test.dart deleted file mode 100644 index 697cc5f0..00000000 --- a/app/test/use_case/add_to_album_test.dart +++ /dev/null @@ -1,401 +0,0 @@ -import 'package:event_bus/event_bus.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/cover_provider.dart'; -import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/album/sort_provider.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/sqlite/database.dart' as sql; -import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/use_case/add_to_album.dart'; -import 'package:np_common/ci_string.dart'; -import 'package:test/test.dart'; - -import '../mock_type.dart'; -import '../test_util.dart' as util; - -void main() { - KiwiContainer().registerInstance(MockEventBus()); - - group("AddToAlbum", () { - test("file", _addFile); - test("ignore existing file", _addExistingFile); - test("ignore existing file (shared)", _addExistingSharedFile); - group("shared album (owned)", () { - test("file", _addFileToSharedAlbumOwned); - test("file owned by user", _addFileOwnedByUserToSharedAlbumOwned); - }); - group("shared album (not owned)", () { - test("file", _addFileToMultiuserSharedAlbumNotOwned); - }); - }); -} - -/// Add a [File] to an [Album] -/// -/// Expect: file added to album -Future _addFile() async { - final account = util.buildAccount(); - final file = (util.FilesBuilder(initialFileId: 1)..addJpeg("admin/test1.jpg")) - .build()[0]; - final album = util.AlbumBuilder().build(); - final albumFile = album.albumFile!; - final c = DiContainer( - fileRepo: MockFileMemoryRepo(), - albumRepo: MockAlbumMemoryRepo([album]), - shareRepo: MockShareRepo(), - sqliteDb: util.buildTestDb(), - pref: Pref.scoped(PrefMemoryProvider()), - ); - addTearDown(() => c.sqliteDb.close()); - await c.sqliteDb.transaction(() async { - await c.sqliteDb.insertAccountOf(account); - await util.insertFiles(c.sqliteDb, account, [file]); - }); - - await AddToAlbum(c)( - account, - c.albumMemoryRepo.findAlbumByPath(albumFile.path), - [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: file, - ), - ], - ); - expect( - c.albumMemoryRepo.albums - .map((e) => e.copyWith( - // we need to set a known value to lastUpdated - lastUpdated: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)), - )) - .toList(), - [ - Album( - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test", - provider: AlbumStaticProvider( - items: [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: file, - ).minimize(), - ], - latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5), - ), - coverProvider: AlbumAutoCoverProvider( - coverFile: file, - ), - sortProvider: const AlbumNullSortProvider(), - albumFile: albumFile, - ), - ], - ); -} - -/// Add a [File], already included in the [Album], to an [Album] -/// -/// Expect: file not added to album -Future _addExistingFile() async { - final account = util.buildAccount(); - final files = - (util.FilesBuilder(initialFileId: 1)..addJpeg("admin/test1.jpg")).build(); - final album = (util.AlbumBuilder()..addFileItem(files[0])).build(); - final oldFile = files[0]; - final newFile = files[0].copyWith(); - final albumFile = album.albumFile!; - final c = DiContainer( - fileRepo: MockFileMemoryRepo(), - albumRepo: MockAlbumMemoryRepo([album]), - shareRepo: MockShareRepo(), - sqliteDb: util.buildTestDb(), - pref: Pref.scoped(PrefMemoryProvider()), - ); - addTearDown(() => c.sqliteDb.close()); - await c.sqliteDb.transaction(() async { - await c.sqliteDb.insertAccountOf(account); - await util.insertFiles(c.sqliteDb, account, files); - }); - - await AddToAlbum(c)( - account, - c.albumMemoryRepo.findAlbumByPath(albumFile.path), - [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: newFile, - ), - ], - ); - expect( - c.albumMemoryRepo.albums - .map((e) => e.copyWith( - // we need to set a known value to lastUpdated - lastUpdated: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)), - )) - .toList(), - [ - Album( - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test", - provider: AlbumStaticProvider( - items: [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: files[0], - ), - ], - latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5), - ), - coverProvider: AlbumAutoCoverProvider(coverFile: files[0]), - sortProvider: const AlbumNullSortProvider(), - albumFile: albumFile, - ), - ], - ); - // when there's a conflict, it's guaranteed that the original file in the - // album is kept and the incoming file dropped - expect( - identical( - AlbumStaticProvider.of(c.albumMemoryRepo.albums[0]) - .items - .whereType() - .first - .file, - oldFile), - true); - expect( - identical( - AlbumStaticProvider.of(c.albumMemoryRepo.albums[0]) - .items - .whereType() - .first - .file, - newFile), - false); -} - -/// Add a file shared with you to an album, where the file is already included -/// -/// Expect: file not added to album -Future _addExistingSharedFile() async { - final account = util.buildAccount(); - final user1Account = util.buildAccount(userId: "user1"); - final files = - (util.FilesBuilder(initialFileId: 1)..addJpeg("admin/test1.jpg")).build(); - final user1Files = [ - files[0].copyWith(path: "remote.php/dav/files/user1/test1.jpg") - ]; - final album = (util.AlbumBuilder()..addFileItem(files[0])).build(); - final albumFile = album.albumFile!; - final c = DiContainer( - fileRepo: MockFileMemoryRepo(), - albumRepo: MockAlbumMemoryRepo([album]), - shareRepo: MockShareRepo(), - sqliteDb: util.buildTestDb(), - pref: Pref.scoped(PrefMemoryProvider()), - ); - addTearDown(() => c.sqliteDb.close()); - await c.sqliteDb.transaction(() async { - await c.sqliteDb.insertAccountOf(account); - await c.sqliteDb.insertAccountOf(user1Account); - await util.insertFiles(c.sqliteDb, account, files); - await util.insertFiles(c.sqliteDb, user1Account, user1Files); - }); - - await AddToAlbum(c)( - account, - c.albumMemoryRepo.findAlbumByPath(albumFile.path), - [ - AlbumFileItem( - addedBy: "user1".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: user1Files[0], - ), - ], - ); - expect( - c.albumMemoryRepo.albums - .map((e) => e.copyWith( - // we need to set a known value to lastUpdated - lastUpdated: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)), - )) - .toList(), - [ - Album( - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test", - provider: AlbumStaticProvider( - items: [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: files[0], - ), - ], - latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5), - ), - coverProvider: AlbumAutoCoverProvider(coverFile: files[0]), - sortProvider: const AlbumNullSortProvider(), - albumFile: albumFile, - ), - ], - ); -} - -/// Add a file to a shared album (admin -> user1) -/// -/// Expect: a new share (admin -> user1) is created for the file -Future _addFileToSharedAlbumOwned() async { - final account = util.buildAccount(); - final file = (util.FilesBuilder(initialFileId: 1)..addJpeg("admin/test1.jpg")) - .build()[0]; - final album = (util.AlbumBuilder()..addShare("user1")).build(); - final albumFile = album.albumFile!; - final c = DiContainer( - fileRepo: MockFileMemoryRepo(), - albumRepo: MockAlbumMemoryRepo([album]), - shareRepo: MockShareMemoryRepo([ - util.buildShare(id: "0", file: albumFile, shareWith: "user1"), - ]), - sqliteDb: util.buildTestDb(), - pref: Pref.scoped(PrefMemoryProvider({ - "isLabEnableSharedAlbum": true, - })), - ); - addTearDown(() => c.sqliteDb.close()); - await c.sqliteDb.transaction(() async { - await c.sqliteDb.insertAccountOf(account); - await util.insertFiles(c.sqliteDb, account, [file]); - }); - - await AddToAlbum(c)( - account, - c.albumMemoryRepo.findAlbumByPath(albumFile.path), - [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: file, - ), - ], - ); - expect( - c.shareMemoryRepo.shares, - [ - util.buildShare(id: "0", file: albumFile, shareWith: "user1"), - util.buildShare(id: "1", file: file, shareWith: "user1"), - ], - ); -} - -/// Add a file owned by user (user1) to a shared album (admin -> user1) -/// -/// Expect: no shares created -Future _addFileOwnedByUserToSharedAlbumOwned() async { - final account = util.buildAccount(); - final file = (util.FilesBuilder(initialFileId: 1) - ..addJpeg("admin/test1.jpg", ownerId: "user1")) - .build()[0]; - final album = (util.AlbumBuilder()..addShare("user1")).build(); - final albumFile = album.albumFile!; - final c = DiContainer( - fileRepo: MockFileMemoryRepo(), - albumRepo: MockAlbumMemoryRepo([album]), - shareRepo: MockShareMemoryRepo([ - util.buildShare(id: "0", file: albumFile, shareWith: "user1"), - ]), - sqliteDb: util.buildTestDb(), - pref: Pref.scoped(PrefMemoryProvider({ - "isLabEnableSharedAlbum": true, - })), - ); - addTearDown(() => c.sqliteDb.close()); - await c.sqliteDb.transaction(() async { - await c.sqliteDb.insertAccountOf(account); - await util.insertFiles(c.sqliteDb, account, [file]); - }); - - await AddToAlbum(c)( - account, - c.albumMemoryRepo.findAlbumByPath(albumFile.path), - [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: file, - ), - ], - ); - expect( - c.shareMemoryRepo.shares, - [ - util.buildShare(id: "0", file: albumFile, shareWith: "user1"), - ], - ); -} - -/// Add a file to a shared album (user1 -> admin, user2) -/// -/// Expect: a new share (admin -> user1, user2) is created for the file -Future _addFileToMultiuserSharedAlbumNotOwned() async { - // doesn't work right now, skipped - final account = util.buildAccount(); - final file = (util.FilesBuilder(initialFileId: 1)..addJpeg("admin/test1.jpg")) - .build()[0]; - final album = (util.AlbumBuilder(ownerId: "user1") - ..addShare("admin") - ..addShare("user2")) - .build(); - final albumFile = album.albumFile!; - final c = DiContainer( - fileRepo: MockFileMemoryRepo(), - albumRepo: MockAlbumMemoryRepo([album]), - shareRepo: MockShareMemoryRepo([ - util.buildShare( - id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"), - util.buildShare( - id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"), - ]), - sqliteDb: util.buildTestDb(), - pref: Pref.scoped(PrefMemoryProvider({ - "isLabEnableSharedAlbum": true, - })), - ); - addTearDown(() => c.sqliteDb.close()); - await c.sqliteDb.transaction(() async { - await c.sqliteDb.insertAccountOf(account); - await util.insertFiles(c.sqliteDb, account, [file]); - }); - - await AddToAlbum(c)( - account, - c.albumMemoryRepo.findAlbumByPath(albumFile.path), - [ - AlbumFileItem( - addedBy: "admin".toCi(), - addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), - file: file, - ), - ], - ); - expect( - c.shareMemoryRepo.shares, - [ - util.buildShare( - id: "0", uidOwner: "user1", file: albumFile, shareWith: "admin"), - util.buildShare( - id: "1", uidOwner: "user1", file: albumFile, shareWith: "user2"), - // the order for these two shares are actually NOT guaranteed - util.buildShare(id: "2", file: file, shareWith: "user2"), - util.buildShare(id: "3", file: file, shareWith: "user1"), - ], - ); -} diff --git a/app/test/use_case/remove_album_test.dart b/app/test/use_case/remove_album_test.dart index 24497b08..f88a8af7 100644 --- a/app/test/use_case/remove_album_test.dart +++ b/app/test/use_case/remove_album_test.dart @@ -3,7 +3,7 @@ import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/use_case/remove_album.dart'; +import 'package:nc_photos/use_case/album/remove_album.dart'; import 'package:test/test.dart'; import '../mock_type.dart'; diff --git a/app/test/use_case/remove_from_album_test.dart b/app/test/use_case/remove_from_album_test.dart index 3a0b5706..12f5d1a2 100644 --- a/app/test/use_case/remove_from_album_test.dart +++ b/app/test/use_case/remove_from_album_test.dart @@ -7,7 +7,7 @@ import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/use_case/remove_from_album.dart'; +import 'package:nc_photos/use_case/album/remove_from_album.dart'; import 'package:test/test.dart'; import '../mock_type.dart'; diff --git a/np_api/lib/np_api.dart b/np_api/lib/np_api.dart index 63ee52c6..e8d9a56e 100644 --- a/np_api/lib/np_api.dart +++ b/np_api/lib/np_api.dart @@ -3,6 +3,7 @@ export 'src/entity/entity.dart'; export 'src/entity/face_parser.dart'; export 'src/entity/favorite_parser.dart'; export 'src/entity/file_parser.dart'; +export 'src/entity/nc_album_parser.dart'; export 'src/entity/person_parser.dart'; export 'src/entity/share_parser.dart'; export 'src/entity/sharee_parser.dart'; diff --git a/np_api/lib/src/api.dart b/np_api/lib/src/api.dart index 21675185..dcf0ac95 100644 --- a/np_api/lib/src/api.dart +++ b/np_api/lib/src/api.dart @@ -11,6 +11,8 @@ part 'direct_api.dart'; part 'face_recognition_api.dart'; part 'files_api.dart'; part 'files_sharing_api.dart'; +part 'photos_api.dart'; +part 'status_api.dart'; part 'systemtag_api.dart'; @npLog @@ -23,6 +25,8 @@ class Api { ApiOcs ocs() => ApiOcs(this); + ApiPhotos photos(String userId) => ApiPhotos(this, userId); + ApiSystemtags systemtags() => ApiSystemtags(this); ApiSystemtagsRelations systemtagsRelations() => ApiSystemtagsRelations(this); diff --git a/np_api/lib/src/api.g.dart b/np_api/lib/src/api.g.dart index 5b63e654..5f649a80 100644 --- a/np_api/lib/src/api.g.dart +++ b/np_api/lib/src/api.g.dart @@ -63,6 +63,27 @@ extension _$ApiOcsFilesSharingShareesNpLog on ApiOcsFilesSharingSharees { static final log = Logger("src.api.ApiOcsFilesSharingSharees"); } +extension _$ApiPhotosAlbumsNpLog on ApiPhotosAlbums { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("src.api.ApiPhotosAlbums"); +} + +extension _$ApiPhotosAlbumNpLog on ApiPhotosAlbum { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("src.api.ApiPhotosAlbum"); +} + +extension _$ApiStatusNpLog on ApiStatus { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("src.api.ApiStatus"); +} + extension _$ApiSystemtagsNpLog on ApiSystemtags { // ignore: unused_element Logger get _log => log; diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index 42f0ac27..1f4071d5 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:np_common/type.dart'; import 'package:to_string/to_string.dart'; part 'entity.g.dart'; @@ -102,6 +103,35 @@ class File with EquatableMixin { final Map? customProperties; } +@toString +class NcAlbum with EquatableMixin { + const NcAlbum({ + required this.href, + required this.lastPhoto, + required this.nbItems, + required this.location, + required this.dateRange, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [ + href, + lastPhoto, + nbItems, + location, + dateRange, + ]; + + final String href; + final int? lastPhoto; + final int? nbItems; + final String? location; + final JsonObj? dateRange; +} + @toString class Person with EquatableMixin { const Person({ @@ -207,6 +237,26 @@ class Sharee with EquatableMixin { final String? shareWithDisplayNameUnique; } +@toString +class Status with EquatableMixin { + const Status({ + required this.version, + required this.versionString, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [ + version, + versionString, + ]; + + final String version; + final String versionString; +} + @toString class Tag with EquatableMixin { const Tag({ diff --git a/np_api/lib/src/entity/entity.g.dart b/np_api/lib/src/entity/entity.g.dart index f6515466..dff92e0d 100644 --- a/np_api/lib/src/entity/entity.g.dart +++ b/np_api/lib/src/entity/entity.g.dart @@ -27,6 +27,13 @@ extension _$FileToString on File { } } +extension _$NcAlbumToString on NcAlbum { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NcAlbum {href: $href, lastPhoto: $lastPhoto, nbItems: $nbItems, location: $location, dateRange: $dateRange}"; + } +} + extension _$PersonToString on Person { String _$toString() { // ignore: unnecessary_string_interpolations @@ -48,6 +55,13 @@ extension _$ShareeToString on Sharee { } } +extension _$StatusToString on Status { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "Status {version: $version, versionString: $versionString}"; + } +} + extension _$TagToString on Tag { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/np_api/lib/src/entity/nc_album_parser.dart b/np_api/lib/src/entity/nc_album_parser.dart new file mode 100644 index 00000000..fd890f7e --- /dev/null +++ b/np_api/lib/src/entity/nc_album_parser.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:np_api/src/entity/entity.dart'; +import 'package:np_api/src/entity/parser.dart'; +import 'package:np_common/log.dart'; +import 'package:np_common/type.dart'; +import 'package:xml/xml.dart'; + +class NcAlbumParser extends XmlResponseParser { + Future> parse(String response) => + compute(_parseNcAlbumsIsolate, response); + + List _parse(XmlDocument xml) => parseT(xml, _toNcAlbum); + + /// Map contents to NcAlbum + NcAlbum _toNcAlbum(XmlElement element) { + String? href; + int? lastPhoto; + int? nbItems; + String? location; + JsonObj? dateRange; + + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("href", + prefix: "DAV:", namespaces: namespaces)) { + href = Uri.decodeComponent(child.innerText); + } else if (child.matchQualifiedName("propstat", + prefix: "DAV:", namespaces: namespaces)) { + final status = child.children + .whereType() + .firstWhere((element) => element.matchQualifiedName("status", + prefix: "DAV:", namespaces: namespaces)) + .innerText; + if (!status.contains(" 200 ")) { + continue; + } + final prop = child.children.whereType().firstWhere( + (element) => element.matchQualifiedName("prop", + prefix: "DAV:", namespaces: namespaces)); + final propParser = _PropParser(namespaces: namespaces); + propParser.parse(prop); + lastPhoto = propParser.lastPhoto; + nbItems = propParser.nbItems; + location = propParser.location; + dateRange = propParser.dateRange; + } + } + + return NcAlbum( + href: href!, + lastPhoto: lastPhoto, + nbItems: nbItems, + location: location, + dateRange: dateRange, + ); + } +} + +class _PropParser { + _PropParser({ + this.namespaces = const {}, + }); + + /// Parse element contents + void parse(XmlElement element) { + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("last-photo", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _lastPhoto = + child.innerText.isEmpty ? null : int.parse(child.innerText); + } else if (child.matchQualifiedName("nbItems", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _nbItems = child.innerText.isEmpty ? null : int.parse(child.innerText); + } else if (child.matchQualifiedName("location", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _location = child.innerText.isEmpty ? null : child.innerText; + } else if (child.matchQualifiedName("dateRange", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _dateRange = + child.innerText.isEmpty ? null : jsonDecode(child.innerText); + } + } + } + + int? get lastPhoto => _lastPhoto; + int? get nbItems => _nbItems; + String? get location => _location; + JsonObj? get dateRange => _dateRange; + + final Map namespaces; + + int? _lastPhoto; + int? _nbItems; + String? _location; + JsonObj? _dateRange; +} + +List _parseNcAlbumsIsolate(String response) { + initLog(); + final xml = XmlDocument.parse(response); + return NcAlbumParser()._parse(xml); +} diff --git a/np_api/lib/src/entity/status_parser.dart b/np_api/lib/src/entity/status_parser.dart new file mode 100644 index 00000000..e30080f7 --- /dev/null +++ b/np_api/lib/src/entity/status_parser.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:np_api/src/entity/entity.dart'; +import 'package:np_common/type.dart'; + +class StatusParser { + Future parse(String response) async { + final json = (jsonDecode(response) as Map).cast(); + return _parse(json); + } + + Status _parse(JsonObj json) { + return Status( + version: json["version"], + versionString: json["versionstring"], + ); + } +} diff --git a/np_api/lib/src/files_api.dart b/np_api/lib/src/files_api.dart index 7e255b3b..403ba72a 100644 --- a/np_api/lib/src/files_api.dart +++ b/np_api/lib/src/files_api.dart @@ -252,7 +252,7 @@ class ApiFiles { try { return await _api.request("MKCOL", path); } catch (e) { - _log.severe("[mkcol] Failed while get", e); + _log.severe("[mkcol] Failed while MKCOL", e); rethrow; } } diff --git a/np_api/lib/src/photos_api.dart b/np_api/lib/src/photos_api.dart new file mode 100644 index 00000000..f2bd51b8 --- /dev/null +++ b/np_api/lib/src/photos_api.dart @@ -0,0 +1,202 @@ +part of 'api.dart'; + +class ApiPhotos { + const ApiPhotos(this.api, this.userId); + + ApiPhotosAlbums albums() => ApiPhotosAlbums(this); + ApiPhotosAlbum album(String name) => ApiPhotosAlbum(this, name); + + final Api api; + final String userId; +} + +@npLog +class ApiPhotosAlbums { + const ApiPhotosAlbums(this.photos); + + /// Retrieve all albums associated with a user + Future propfind({ + lastPhoto, + nbItems, + location, + dateRange, + collaborators, + }) async { + final endpoint = "remote.php/dav/photos/${photos.userId}/albums"; + try { + if (lastPhoto == null && + nbItems == null && + location == null && + dateRange == null && + collaborators == null) { + // no body + return await api.request("PROPFIND", endpoint); + } + + final namespaces = { + "DAV:": "d", + "http://nextcloud.org/ns": "nc", + }; + final builder = XmlBuilder(); + builder + ..processing("xml", "version=\"1.0\"") + ..element("d:propfind", namespaces: namespaces, nest: () { + builder.element("d:prop", nest: () { + if (lastPhoto != null) { + builder.element("nc:last-photo"); + } + if (nbItems != null) { + builder.element("nc:nbItems"); + } + if (location != null) { + builder.element("nc:location"); + } + if (dateRange != null) { + builder.element("nc:dateRange"); + } + if (collaborators != null) { + builder.element("nc:collaborators"); + } + }); + }); + return await api.request( + "PROPFIND", + endpoint, + header: { + "Content-Type": "application/xml", + }, + body: builder.buildDocument().toXmlString(), + ); + } catch (e) { + _log.severe("[propfind] Failed while propfind", e); + rethrow; + } + } + + Api get api => photos.api; + final ApiPhotos photos; +} + +@npLog +class ApiPhotosAlbum { + const ApiPhotosAlbum(this.photos, this.albumId); + + /// Retrieve all albums associated with a user + Future propfind({ + getcontentlength, + getcontenttype, + getetag, + getlastmodified, + resourcetype, + faceDetections, + fileMetadataSize, + hasPreview, + realpath, + favorite, + fileid, + permissions, + }) async { + final endpoint = "remote.php/dav/photos/${photos.userId}/albums/$albumId"; + try { + final bool hasDavNs = (getcontentlength != null || + getcontenttype != null || + getetag != null || + getlastmodified != null || + resourcetype != null); + final bool hasNcNs = (faceDetections != null || + fileMetadataSize != null || + hasPreview != null || + realpath != null); + final bool hasOcNs = + (favorite != null || fileid != null || permissions != null); + if (!hasDavNs && !hasOcNs && !hasNcNs) { + // no body + return await api.request("PROPFIND", endpoint); + } + + final namespaces = { + "DAV:": "d", + if (hasOcNs) "http://owncloud.org/ns": "oc", + if (hasNcNs) "http://nextcloud.org/ns": "nc", + }; + final builder = XmlBuilder(); + builder + ..processing("xml", "version=\"1.0\"") + ..element("d:propfind", namespaces: namespaces, nest: () { + builder.element("d:prop", nest: () { + if (getcontentlength != null) { + builder.element("d:getcontentlength"); + } + if (getcontenttype != null) { + builder.element("d:getcontenttype"); + } + if (getetag != null) { + builder.element("d:getetag"); + } + if (getlastmodified != null) { + builder.element("d:getlastmodified"); + } + if (resourcetype != null) { + builder.element("d:resourcetype"); + } + if (faceDetections != null) { + builder.element("nc:face-detections"); + } + if (fileMetadataSize != null) { + builder.element("nc:file-metadata-size"); + } + if (hasPreview != null) { + builder.element("nc:has-preview"); + } + if (realpath != null) { + builder.element("nc:realpath"); + } + if (favorite != null) { + builder.element("oc:favorite"); + } + if (fileid != null) { + builder.element("oc:fileid"); + } + if (permissions != null) { + builder.element("oc:permissions"); + } + }); + }); + return await api.request( + "PROPFIND", + endpoint, + header: { + "Content-Type": "application/xml", + }, + body: builder.buildDocument().toXmlString(), + ); + } catch (e) { + _log.severe("[propfind] Failed while propfind", e); + rethrow; + } + } + + Future mkcol() async { + try { + final endpoint = "remote.php/dav/photos/${photos.userId}/albums/$albumId"; + return await api.request("MKCOL", endpoint); + } catch (e) { + _log.severe("[mkcol] Failed while MKCOL", e); + rethrow; + } + } + + Future delete() async { + try { + final endpoint = "remote.php/dav/photos/${photos.userId}/albums/$albumId"; + return await api.request("DELETE", endpoint); + } catch (e) { + _log.severe("[delete] Failed while DELETE", e); + rethrow; + } + } + + Api get api => photos.api; + final ApiPhotos photos; + final String albumId; +} diff --git a/np_api/lib/src/status_api.dart b/np_api/lib/src/status_api.dart new file mode 100644 index 00000000..3b5d56af --- /dev/null +++ b/np_api/lib/src/status_api.dart @@ -0,0 +1,18 @@ +part of 'api.dart'; + +@npLog +class ApiStatus { + const ApiStatus(this.api); + + Future get() async { + const endpoint = "status.php"; + try { + return await api.request("GET", endpoint); + } catch (e) { + _log.severe("[get] Failed while get", e); + rethrow; + } + } + + final Api api; +} diff --git a/np_common/lib/type.dart b/np_common/lib/type.dart index e9f8c9e4..06f3b6bd 100644 --- a/np_common/lib/type.dart +++ b/np_common/lib/type.dart @@ -1 +1,9 @@ typedef JsonObj = Map; + +typedef ErrorHandler = void Function(Object error, StackTrace? stackTrace); + +typedef ErrorWithValueHandler = void Function( + T value, Object error, StackTrace? stackTrace); + +typedef ErrorWithValueIndexedHandler = void Function( + int index, T value, Object error, StackTrace? stackTrace);