From f3901b5ff0361720fda1fa1fcc7b77cc7b245b06 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 13 Apr 2023 23:32:31 +0800 Subject: [PATCH 01/67] 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); From 3ab3f5ceb9a7423bfc931c7f2ca7b72b2feaa0c9 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 15 Apr 2023 03:12:00 +0800 Subject: [PATCH 02/67] Regression: scroll the list when dragging an item near phone edge --- app/lib/widget/collection_browser.dart | 144 ++++++++++++------ app/lib/widget/collection_browser.g.dart | 12 +- app/lib/widget/collection_browser/bloc.dart | 7 + .../collection_browser/state_event.dart | 14 ++ app/lib/widget/draggable_item_list.dart | 10 ++ 5 files changed, 140 insertions(+), 47 deletions(-) diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index eefee6f9..3f73194b 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -191,55 +191,59 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> 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( + Listener( + onPointerMove: (event) => _onPointerMove(context, event), + child: CustomScrollView( + controller: _scrollController, + slivers: [ + _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(); + 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 SliverIgnorePointer( - ignoring: true, - sliver: SliverOpacity( - opacity: .25, - sliver: _ContentList(), - ), - ); + 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) => @@ -263,7 +267,52 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> ); } + void _onPointerMove(BuildContext context, PointerMoveEvent event) { + final bloc = context.read<_Bloc>(); + if (!bloc.state.isDragging) { + return; + } + if (event.position.dy >= MediaQuery.of(context).size.height - 100) { + // near bottom of screen + if (_isDragScrollingDown == true) { + return; + } + // using an arbitrary big number to save time needed to calculate the + // actual extent + const maxExtent = 1000000000.0; + _log.fine("[_onPointerMove] Begin scrolling down"); + if (_scrollController.offset < + _scrollController.position.maxScrollExtent) { + _scrollController.animateTo(maxExtent, + duration: Duration( + milliseconds: + ((maxExtent - _scrollController.offset) * 1.6).round()), + curve: Curves.linear); + _isDragScrollingDown = true; + } + } else if (event.position.dy <= 100) { + // near top of screen + if (_isDragScrollingDown == false) { + return; + } + _log.fine("[_onPointerMove] Begin scrolling up"); + if (_scrollController.offset > 0) { + _scrollController.animateTo(0, + duration: Duration( + milliseconds: (_scrollController.offset * 1.6).round()), + curve: Curves.linear); + _isDragScrollingDown = false; + } + } else if (_isDragScrollingDown != null) { + _log.fine("[_onPointerMove] Stop scrolling"); + _scrollController.jumpTo(_scrollController.offset); + _isDragScrollingDown = null; + } + } + late final _bloc = context.read<_Bloc>(); + final _scrollController = ScrollController(); + bool? _isDragScrollingDown; } class _ContentList extends StatelessWidget { @@ -354,6 +403,9 @@ class _EditContentList extends StatelessWidget { onDragResult: (results) { context.read<_Bloc>().add(_EditManualSort(results)); }, + onDraggingChanged: (value) { + context.read<_Bloc>().add(_SetDragging(value)); + }, ); } else { return SelectableItemList<_Item>( diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index 4f64e2dc..52a560a0 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -28,6 +28,7 @@ abstract class $_StateCopyWithWorker { List? editItems, List<_Item>? editTransformedItems, CollectionItemSort? editSort, + bool? isDragging, ExceptionEvent? error, String? message}); } @@ -51,6 +52,7 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { dynamic editItems = copyWithNull, dynamic editTransformedItems = copyWithNull, dynamic editSort = copyWithNull, + dynamic isDragging, dynamic error = copyWithNull, dynamic message = copyWithNull}) { return _State( @@ -79,6 +81,7 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { editSort: editSort == copyWithNull ? that.editSort : editSort as CollectionItemSort?, + isDragging: isDragging as bool? ?? that.isDragging, error: error == copyWithNull ? that.error : error as ExceptionEvent?, message: message == copyWithNull ? that.message : message as String?); } @@ -125,7 +128,7 @@ extension _$_BlocNpLog on _Bloc { 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}"; + 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}"}, isDragging: $isDragging, error: $error, message: $message}"; } } @@ -250,6 +253,13 @@ extension _$_DeleteSelectedItemsToString on _DeleteSelectedItems { } } +extension _$_SetDraggingToString on _SetDragging { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetDragging {flag: $flag}"; + } +} + extension _$_SetErrorToString on _SetError { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index 3f0a15af..6cca6740 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -36,6 +36,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { transformer: concurrent()); on<_DeleteSelectedItems>(_onDeleteSelectedItems); + on<_SetDragging>(_onSetDragging); + on<_SetError>(_onSetError); on<_SetMessage>(_onSetMessage); @@ -287,6 +289,11 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { } } + void _onSetDragging(_SetDragging ev, Emitter<_State> emit) { + _log.info("$ev"); + emit(state.copyWith(isDragging: ev.flag)); + } + void _onSetError(_SetError ev, Emitter<_State> emit) { _log.info("$ev"); emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index 6a544504..5821e1f4 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -18,6 +18,7 @@ class _State { this.editItems, this.editTransformedItems, this.editSort, + required this.isDragging, this.error, this.message, }); @@ -37,6 +38,7 @@ class _State { isSelectionManageableFile: true, isEditMode: false, isEditBusy: false, + isDragging: false, ); } @@ -62,6 +64,8 @@ class _State { final List<_Item>? editTransformedItems; final CollectionItemSort? editSort; + final bool isDragging; + final ExceptionEvent? error; final String? message; } @@ -235,6 +239,16 @@ class _DeleteSelectedItems implements _Event { String toString() => _$toString(); } +@toString +class _SetDragging implements _Event { + const _SetDragging(this.flag); + + @override + String toString() => _$toString(); + + final bool flag; +} + @toString class _SetError implements _Event { const _SetError(this.error, [this.stackTrace]); diff --git a/app/lib/widget/draggable_item_list.dart b/app/lib/widget/draggable_item_list.dart index 74d903ee..5d70fe3f 100644 --- a/app/lib/widget/draggable_item_list.dart +++ b/app/lib/widget/draggable_item_list.dart @@ -26,6 +26,7 @@ class DraggableItemList required this.itemDragFeedbackBuilder, required this.staggeredTileBuilder, this.onDragResult, + this.onDraggingChanged, }); @override @@ -43,6 +44,9 @@ class DraggableItemList /// /// [results] contains the rearranged items final void Function(List results)? onDragResult; + + /// Called when user started (true) or ended (false) dragging + final ValueChanged? onDraggingChanged; } @npLog @@ -62,6 +66,12 @@ class _DraggableItemListState feedback: widget.itemDragFeedbackBuilder?.call(context, i, meta), onDropBefore: (data) => _onMoved(data.index, i, true), onDropAfter: (data) => _onMoved(data.index, i, false), + onDragStarted: () { + widget.onDraggingChanged?.call(true); + }, + onDragEndedAny: () { + widget.onDraggingChanged?.call(false); + }, feedbackSize: Size(widget.maxCrossAxisExtent * .65, widget.maxCrossAxisExtent * .65), child: widget.itemBuilder(context, i, meta), From ecf085e0b3089f878ab10dadc94758541c49ce36 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 15 Apr 2023 13:26:19 +0800 Subject: [PATCH 03/67] Regression: download album as a whole --- app/lib/widget/collection_browser.g.dart | 7 ++++ .../widget/collection_browser/app_bar.dart | 40 ++++++++++++------- app/lib/widget/collection_browser/bloc.dart | 13 ++++++ .../collection_browser/state_event.dart | 8 ++++ 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index 52a560a0..75e7b253 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -153,6 +153,13 @@ extension _$_TransformItemsToString on _TransformItems { } } +extension _$_DownloadToString on _Download { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Download {}"; + } +} + extension _$_BeginEditToString on _BeginEdit { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 3c41d86a..938d3098 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -33,20 +33,28 @@ class _AppBar extends StatelessWidget { }, ), 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); - }, + _BlocBuilder( + buildWhen: (previous, current) => previous.items != current.items, + builder: (context, state) => PopupMenuButton<_MenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) { + return [ + if (capabilities.contains(CollectionCapability.rename)) + PopupMenuItem( + value: _MenuOption.edit, + child: Text(L10n.global().editTooltip), + ), + if (state.items.isNotEmpty) + PopupMenuItem( + value: _MenuOption.download, + child: Text(L10n.global().downloadTooltip), + ), + ]; + }, + onSelected: (option) { + _onMenuSelected(context, option); + }, + ), ), ], ); @@ -57,6 +65,9 @@ class _AppBar extends StatelessWidget { case _MenuOption.edit: context.read<_Bloc>().add(const _BeginEdit()); break; + case _MenuOption.download: + context.read<_Bloc>().add(const _Download()); + break; } } } @@ -343,6 +354,7 @@ class _EditAppBar extends StatelessWidget { enum _MenuOption { edit, + download, } enum _SelectionMenuOption { diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index 6cca6740..e101948e 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -18,6 +18,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_LoadItems>(_onLoad); on<_TransformItems>(_onTransformItems); + on<_Download>(_onDownload); + on<_BeginEdit>(_onBeginEdit); on<_EditName>(_onEditName); on<_AddLabelToCollection>(_onAddLabelToCollection); @@ -103,6 +105,17 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { emit(newState); } + void _onDownload(_Download ev, Emitter<_State> emit) { + _log.info("$ev"); + if (state.items.isNotEmpty) { + unawaited(DownloadHandler(_c).downloadFiles( + account, + state.items.whereType().map((e) => e.file).toList(), + parentDir: state.collection.name, + )); + } + } + void _onBeginEdit(_BeginEdit ev, Emitter<_State> emit) { _log.info("$ev"); emit(state.copyWith(isEditMode: true)); diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index 5821e1f4..4fbf463f 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -104,6 +104,14 @@ class _TransformItems implements _Event { final List items; } +@toString +class _Download implements _Event { + const _Download(); + + @override + String toString() => _$toString(); +} + @toString class _BeginEdit implements _Event { const _BeginEdit(); From 004a93a24f7afcf0dce91876e5a893fe8d480c23 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 15 Apr 2023 16:39:24 +0800 Subject: [PATCH 04/67] Fix edit menu not shown in dynamic album --- app/lib/entity/collection/content_provider/album.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index a6e06e4f..f27845be 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -42,11 +42,11 @@ class CollectionAlbumProvider implements CollectionContentProvider { @override List get capabilities => [ + CollectionCapability.sort, + CollectionCapability.rename, if (album.provider is AlbumStaticProvider) ...[ CollectionCapability.manualItem, - CollectionCapability.sort, CollectionCapability.manualSort, - CollectionCapability.rename, CollectionCapability.labelItem, ], ]; From e56667be79dafb3e7816ccb34b57c968c6db2b1d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 15 Apr 2023 16:39:52 +0800 Subject: [PATCH 05/67] Fix missing visual feedback when changing sort for dynamic album --- app/lib/widget/collection_browser.dart | 48 ++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 3f73194b..48929709 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -231,13 +231,7 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> .contains(CollectionCapability.manualSort)) { return const _EditContentList(); } else { - return const SliverIgnorePointer( - ignoring: true, - sliver: SliverOpacity( - opacity: .25, - sliver: _ContentList(), - ), - ); + return const _UnmodifiableEditContentList(); } } }, @@ -423,3 +417,43 @@ class _EditContentList extends StatelessWidget { ); } } + +/// Unmodifiable content list under edit mode +class _UnmodifiableEditContentList extends StatelessWidget { + const _UnmodifiableEditContentList(); + + @override + Widget build(BuildContext context) { + return SliverIgnorePointer( + ignoring: true, + sliver: SliverOpacity( + opacity: .25, + sliver: 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) { + 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, + ); + }, + ); + }, + ), + ), + ); + } +} From 3d05d01b0bda096dbf29a43bf8cd7216fe1a7f9c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 16 Apr 2023 18:29:23 +0800 Subject: [PATCH 06/67] Remove obsolete widget --- app/lib/widget/album_browser_mixin.dart | 4 +- app/lib/widget/album_browser_util.dart | 23 +- app/lib/widget/dynamic_album_browser.dart | 778 -------------------- app/lib/widget/dynamic_album_browser.g.dart | 26 - app/lib/widget/sharing_browser.dart | 5 +- 5 files changed, 8 insertions(+), 828 deletions(-) delete mode 100644 app/lib/widget/dynamic_album_browser.dart delete mode 100644 app/lib/widget/dynamic_album_browser.g.dart diff --git a/app/lib/widget/album_browser_mixin.dart b/app/lib/widget/album_browser_mixin.dart index 0dc0b4dc..b108e5d9 100644 --- a/app/lib/widget/album_browser_mixin.dart +++ b/app/lib/widget/album_browser_mixin.dart @@ -243,9 +243,7 @@ mixin AlbumBrowserMixin stackTrace); } if (newAlbum != null && mounted) { - unawaited( - album_browser_util.pushReplacement(context, account, newAlbum!), - ); + album_browser_util.pushReplacement(context, account, newAlbum!); } } diff --git a/app/lib/widget/album_browser_util.dart b/app/lib/widget/album_browser_util.dart index bd4fab1f..8ecb12f4 100644 --- a/app/lib/widget/album_browser_util.dart +++ b/app/lib/widget/album_browser_util.dart @@ -3,37 +3,24 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/widget/album_browser.dart'; -import 'package:nc_photos/widget/dynamic_album_browser.dart'; import 'package:nc_photos/widget/smart_album_browser.dart'; /// Push the corresponding browser route for this album -Future push(BuildContext context, Account account, Album album) { +void push(BuildContext context, Account account, Album album) { if (album.provider is AlbumStaticProvider) { - return Navigator.of(context).pushNamed(AlbumBrowser.routeName, + Navigator.of(context).pushNamed(AlbumBrowser.routeName, arguments: AlbumBrowserArguments(account, album)); } else if (album.provider is AlbumSmartProvider) { - return Navigator.of(context).pushNamed(SmartAlbumBrowser.routeName, + Navigator.of(context).pushNamed(SmartAlbumBrowser.routeName, arguments: SmartAlbumBrowserArguments(account, album)); - } else { - return Navigator.of(context).pushNamed(DynamicAlbumBrowser.routeName, - arguments: DynamicAlbumBrowserArguments(account, album)); } } /// Push the corresponding browser route for this album and replace the current /// route -Future pushReplacement( - BuildContext context, Account account, Album album) { +void pushReplacement(BuildContext context, Account account, Album album) { if (album.provider is AlbumStaticProvider) { - return Navigator.of(context).pushReplacementNamed(AlbumBrowser.routeName, + Navigator.of(context).pushReplacementNamed(AlbumBrowser.routeName, arguments: AlbumBrowserArguments(account, album)); - } else if (album.provider is AlbumSmartProvider) { - return Navigator.of(context).pushReplacementNamed( - SmartAlbumBrowser.routeName, - arguments: SmartAlbumBrowserArguments(account, album)); - } else { - return Navigator.of(context).pushReplacementNamed( - DynamicAlbumBrowser.routeName, - arguments: DynamicAlbumBrowserArguments(account, album)); } } diff --git a/app/lib/widget/dynamic_album_browser.dart b/app/lib/widget/dynamic_album_browser.dart deleted file mode 100644 index 4680afbf..00000000 --- a/app/lib/widget/dynamic_album_browser.dart +++ /dev/null @@ -1,778 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.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/debug_util.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/download_handler.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/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/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/object_extension.dart'; -import 'package:nc_photos/or_null.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/use_case/preprocess_album.dart'; -import 'package:nc_photos/use_case/remove.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/fancy_option_picker.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/viewer.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'dynamic_album_browser.g.dart'; - -class DynamicAlbumBrowserArguments { - DynamicAlbumBrowserArguments(this.account, this.album); - - final Account account; - final Album album; -} - -class DynamicAlbumBrowser extends StatefulWidget { - static const routeName = "/dynamic-album-browser"; - - static Route buildRoute(DynamicAlbumBrowserArguments args) => - MaterialPageRoute( - builder: (context) => DynamicAlbumBrowser.fromArgs(args), - ); - - const DynamicAlbumBrowser({ - Key? key, - required this.account, - required this.album, - }) : super(key: key); - - DynamicAlbumBrowser.fromArgs(DynamicAlbumBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - album: args.album, - ); - - @override - createState() => _DynamicAlbumBrowserState(); - - final Account account; - final Album album; -} - -@npLog -class _DynamicAlbumBrowserState extends State - with - SelectableItemStreamListMixin, - AlbumBrowserMixin { - _DynamicAlbumBrowserState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - assert(PreProcessAlbum.require(c)); - _c = c; - } - - static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); - - @override - initState() { - super.initState(); - _initAlbum(); - - _albumUpdatedListener = - AppEventListener(_onAlbumUpdatedEvent); - _albumUpdatedListener.begin(); - _fileRemovedListener.begin(); - } - - @override - dispose() { - super.dispose(); - _albumUpdatedListener.end(); - _fileRemovedListener.end(); - } - - @override - build(BuildContext context) { - return Scaffold( - body: Builder( - builder: (context) { - if (isEditMode) { - return Form( - key: _editFormKey, - child: _buildContent(context), - ); - } else { - return _buildContent(context); - } - }, - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as<_ListItem>()?.onTap?.call(); - } - - @override - @protected - get canEdit => _album?.albumFile?.isOwned(widget.account.userId) == true; - - @override - enterEditMode() { - super.enterEditMode(); - _editAlbum = _album!.copyWith(); - } - - @override - validateEditMode() => _editFormKey.currentState?.validate() == true; - - @override - doneEditMode() { - try { - // persist the changes - _editFormKey.currentState!.save(); - final newAlbum = makeEdited(_editAlbum!); - if (newAlbum.copyWith(lastUpdated: OrNull(_album!.lastUpdated)) != - _album) { - _log.info("[doneEditMode] Album modified: $newAlbum"); - setState(() { - _album = newAlbum; - }); - UpdateAlbum(_c.albumRepo)( - widget.account, - newAlbum, - ).catchError((e, stackTrace) { - _log.shout("[doneEditMode] Failed while UpdateAlbum", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - }); - } else { - _log.fine("[doneEditMode] Album not modified"); - } - } finally { - setState(() { - // reset edits - _editAlbum = null; - // update the list to show the real album - _transformItems(_sortedItems); - }); - } - } - - Future _initAlbum() async { - assert(widget.album.provider is AlbumDynamicProvider); - final List items; - final Album album; - try { - items = await PreProcessAlbum(_c)(widget.account, widget.album); - album = await _updateAlbumPostPopulate(widget.album, items); - } catch (e, stackTrace) { - _log.severe("[_initAlbum] Failed while PreProcessAlbum", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - return; - } - if (mounted) { - setState(() { - _album = album; - _transformItems(items); - initCover(widget.account, widget.album); - }); - } - } - - Widget _buildContent(BuildContext context) { - if (_album == null) { - return CustomScrollView( - slivers: [ - buildNormalAppBar(context, widget.account, widget.album), - const SliverToBoxAdapter( - child: LinearProgressIndicator(), - ), - ], - ); - } - return buildItemStreamListOuter( - context, - child: CustomScrollView( - slivers: [ - _buildAppBar(context), - SliverIgnorePointer( - ignoring: isEditMode, - sliver: SliverOpacity( - opacity: isEditMode ? .25 : 1, - sliver: buildItemStreamList( - maxCrossAxisExtent: thumbSize.toDouble(), - ), - ), - ), - ], - ), - ); - } - - Widget _buildAppBar(BuildContext context) { - if (isEditMode) { - return _buildEditAppBar(context); - } else if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return _buildNormalAppBar(context); - } - } - - Widget _buildNormalAppBar(BuildContext context) { - final menuItems = >[ - PopupMenuItem( - value: _menuValueDownload, - child: Text(L10n.global().downloadTooltip), - ), - ]; - if (canEdit) { - menuItems.add(PopupMenuItem( - value: _menuValueConvertBasic, - child: Text(L10n.global().convertAlbumTooltip), - )); - } - - return buildNormalAppBar( - context, - widget.account, - _album!, - menuItemBuilder: (_) => menuItems, - onSelectedMenuItem: (option) { - switch (option) { - case _menuValueConvertBasic: - _onConvertBasicPressed(context); - break; - case _menuValueDownload: - _onDownloadPressed(); - break; - default: - _log.shout("[_buildNormalAppBar] Unknown value: $option"); - break; - } - }, - ); - } - - Widget _buildSelectionAppBar(BuildContext context) { - return buildSelectionAppBar(context, [ - IconButton( - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - onPressed: () { - _onSelectionSharePressed(context); - }, - ), - PopupMenuButton<_SelectionMenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - PopupMenuItem( - value: _SelectionMenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.delete, - child: Text(L10n.global().deleteTooltip), - ), - ], - onSelected: (option) => _onSelectionMenuSelected(context, option), - ), - ]); - } - - Widget _buildEditAppBar(BuildContext context) { - return buildEditAppBar(context, widget.account, widget.album, actions: [ - IconButton( - icon: const Icon(Icons.sort_by_alpha), - tooltip: L10n.global().sortTooltip, - onPressed: _onEditSortPressed, - ), - ]); - } - - void _onItemTap(int index) { - // convert item index to file index - var fileIndex = index; - for (int i = 0; i < index; ++i) { - if (_sortedItems[i] is! AlbumFileItem || - !file_util - .isSupportedFormat((_sortedItems[i] as AlbumFileItem).file)) { - --fileIndex; - } - } - Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex, - album: widget.album)); - } - - Future _onConvertBasicPressed(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(L10n.global().convertAlbumTooltip), - content: Text(L10n.global().convertAlbumConfirmationDialogContent), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - ), - ); - if (result != true) { - return; - } - _log.info( - "[_onConvertBasicPressed] Converting album '${_album!.name}' to static"); - try { - await UpdateAlbum(_c.albumRepo)( - widget.account, - _album!.copyWith( - provider: AlbumStaticProvider( - latestItemTime: _album!.provider.latestItemTime, - items: _sortedItems, - ), - coverProvider: AlbumAutoCoverProvider(), - )); - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().convertAlbumSuccessNotification), - duration: k.snackBarDurationNormal, - )); - if (mounted) { - Navigator.of(context).pop(); - } - } catch (e, stackTrace) { - _log.shout( - "[_onConvertBasicPressed] Failed while converting to basic album", - e, - stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onDownloadPressed() { - final c = KiwiContainer().resolve(); - DownloadHandler(c).downloadFiles( - widget.account, - _sortedItems.whereType().map((e) => e.file).toList(), - parentDir: _album!.name, - ); - } - - void _onSelectionMenuSelected( - BuildContext context, _SelectionMenuOption option) { - switch (option) { - case _SelectionMenuOption.delete: - _onSelectionDeletePressed(); - 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<_FileListItem>() - .map((e) => e.file) - .toList(); - ShareHandler( - c, - context: context, - clearSelection: () { - setState(() { - clearSelectedItems(); - }); - }, - ).shareFiles(widget.account, selected); - } - - Future _onSelectionDeletePressed() async { - SnackBarManager().showSnackBar( - SnackBar( - content: Text(L10n.global() - .deleteSelectedProcessingNotification(selectedListItems.length)), - duration: k.snackBarDurationShort, - ), - canBeReplaced: true, - ); - - final selected = selectedListItems.whereType<_FileListItem>().toList(); - setState(() { - clearSelectedItems(); - }); - - final successes = <_FileListItem>[]; - await Remove(KiwiContainer().resolve())( - widget.account, - selected.map((e) => e.file).toList(), - onError: (_, f, e, stackTrace) { - _log.shout( - "[_onSelectionDeletePressed] Failed while removing file: ${logFilename(f.fdPath)}", - e, - stackTrace, - ); - successes.removeWhere((item) => item.file.compareServerIdentity(f)); - }, - ); - - if (successes.length == selected.length) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().deleteSelectedSuccessNotification), - duration: k.snackBarDurationNormal, - )); - } else { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().deleteSelectedFailureNotification( - selected.length - successes.length)), - duration: k.snackBarDurationNormal, - )); - } - if (successes.isNotEmpty) { - final indexes = successes.map((e) => e.index).sorted(Comparable.compare); - setState(() { - for (final i in indexes.reversed) { - _sortedItems.removeAt(i); - } - _onSortedItemsUpdated(); - }); - } - } - - void _onSelectionDownloadPressed() { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType<_FileListItem>() - .map((e) => e.file) - .toList(); - DownloadHandler(c).downloadFiles(widget.account, selected); - setState(() { - clearSelectedItems(); - }); - } - - void _onEditSortPressed() { - final sortProvider = _editAlbum!.sortProvider; - showDialog( - context: context, - builder: (context) => FancyOptionPicker( - title: L10n.global().sortOptionDialogTitle, - items: [ - FancyOptionPickerItem( - label: L10n.global().sortOptionTimeDescendingLabel, - isSelected: sortProvider is AlbumTimeSortProvider && - !sortProvider.isAscending, - onSelect: () { - _onEditSortNewestPressed(); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionTimeAscendingLabel, - isSelected: sortProvider is AlbumTimeSortProvider && - sortProvider.isAscending, - onSelect: () { - _onEditSortOldestPressed(); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionFilenameAscendingLabel, - isSelected: sortProvider is AlbumFilenameSortProvider && - sortProvider.isAscending, - onSelect: () { - _onEditSortFilenamePressed(); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionFilenameDescendingLabel, - isSelected: sortProvider is AlbumFilenameSortProvider && - !sortProvider.isAscending, - onSelect: () { - _onEditSortFilenameDescendingPressed(); - Navigator.of(context).pop(); - }, - ), - ], - ), - ); - } - - void _onEditSortOldestPressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumTimeSortProvider(isAscending: true), - ); - setState(() { - _transformItems(_sortedItems); - }); - } - - void _onEditSortNewestPressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumTimeSortProvider(isAscending: false), - ); - setState(() { - _transformItems(_sortedItems); - }); - } - - void _onEditSortFilenamePressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumFilenameSortProvider(isAscending: true), - ); - setState(() { - _transformItems(_sortedItems); - }); - } - - void _onEditSortFilenameDescendingPressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumFilenameSortProvider(isAscending: false), - ); - setState(() { - _transformItems(_sortedItems); - }); - } - - Future _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) async { - if (ev.album.albumFile!.path == _album?.albumFile?.path) { - final album = await _updateAlbumPostPopulate(ev.album, _sortedItems); - setState(() { - _album = album; - initCover(widget.account, album); - }); - } - } - - void _onFileRemovedEvent(FileRemovedEvent ev) { - if (_backingFiles.any((f) => f.compareServerIdentity(ev.file))) { - setState(() { - _sortedItems = _sortedItems.where((i) { - if (i is AlbumFileItem) { - return !i.file.compareServerIdentity(ev.file); - } else { - return true; - } - }).toList(); - _onSortedItemsUpdated(); - }); - } - } - - void _transformItems(List items) { - _sortedItems = (_editAlbum ?? _album)!.sortProvider.sort(items); - _onSortedItemsUpdated(); - } - - void _onSortedItemsUpdated() { - _backingFiles = _sortedItems - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .toList(); - final dateHelper = photo_list_util.DateGroupHelper( - isMonthOnly: false, - ); - - itemStreamListItems = () sync* { - for (int i = 0; i < _sortedItems.length; ++i) { - final item = _sortedItems[i]; - if (item is AlbumFileItem) { - final previewUrl = - NetworkRectThumbnail.imageUrlForFile(widget.account, item.file); - if ((_editAlbum ?? _album)?.sortProvider is AlbumTimeSortProvider && - Pref().isAlbumBrowserShowDateOr()) { - final date = dateHelper.onFile(item.file); - if (date != null) { - yield _DateListItem(date: date); - } - } - - if (file_util.isSupportedImageFormat(item.file)) { - yield _ImageListItem( - index: i, - file: item.file, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - } else if (file_util.isSupportedVideoFormat(item.file)) { - yield _VideoListItem( - index: i, - file: item.file, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - } - } - } - }() - .toList(); - } - - Future _updateAlbumPostPopulate( - Album album, List items) async { - return await UpdateAlbumWithActualItems(_c.albumRepo)( - widget.account, album, items); - } - - late final DiContainer _c; - - Album? _album; - var _sortedItems = []; - var _backingFiles = []; - - final _editFormKey = GlobalKey(); - Album? _editAlbum; - - late AppEventListener _albumUpdatedListener; - late final _fileRemovedListener = - AppEventListener(_onFileRemovedEvent); - - static const _menuValueConvertBasic = 0; - static const _menuValueDownload = 1; -} - -enum _SelectionMenuOption { - delete, - download, -} - -@toString -abstract class _ListItem implements SelectableItem { - const _ListItem({ - required this.index, - this.onTap, - }); - - @override - get isTappable => onTap != null; - - @override - get isSelectable => true; - - @override - get staggeredTile => const StaggeredTile.count(1, 1); - - @override - String toString() => _$toString(); - - final int index; - - @ignore - final VoidCallback? onTap; -} - -abstract class _FileListItem extends _ListItem { - _FileListItem({ - required int index, - required this.file, - VoidCallback? onTap, - }) : super( - index: index, - onTap: onTap, - ); - - final File file; -} - -class _ImageListItem extends _FileListItem { - _ImageListItem({ - required super.index, - required super.file, - required this.account, - required this.previewUrl, - super.onTap, - }); - - @override - buildWidget(BuildContext context) => PhotoListImage( - account: account, - previewUrl: previewUrl, - isGif: file.contentType == "image/gif", - heroKey: flutter_util.getImageHeroTag(file), - ); - - final Account account; - final String previewUrl; -} - -class _VideoListItem extends _FileListItem { - _VideoListItem({ - required super.index, - required super.file, - required this.account, - required this.previewUrl, - super.onTap, - }); - - @override - buildWidget(BuildContext context) => PhotoListVideo( - account: account, - previewUrl: previewUrl, - ); - - final Account account; - final String previewUrl; -} - -class _DateListItem extends _ListItem { - const _DateListItem({ - required this.date, - }) : super(index: -1); - - @override - get isSelectable => false; - - @override - get staggeredTile => const StaggeredTile.extent(99, 32); - - @override - buildWidget(BuildContext context) => PhotoListDate( - date: date, - ); - - final DateTime date; -} diff --git a/app/lib/widget/dynamic_album_browser.g.dart b/app/lib/widget/dynamic_album_browser.g.dart deleted file mode 100644 index 5823beae..00000000 --- a/app/lib/widget/dynamic_album_browser.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'dynamic_album_browser.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_DynamicAlbumBrowserStateNpLog on _DynamicAlbumBrowserState { - // ignore: unused_element - Logger get _log => log; - - static final log = - Logger("widget.dynamic_album_browser._DynamicAlbumBrowserState"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$_ListItemToString on _ListItem { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "_ListItem")} {index: $index}"; - } -} diff --git a/app/lib/widget/sharing_browser.dart b/app/lib/widget/sharing_browser.dart index 8d824c58..e5c76186 100644 --- a/app/lib/widget/sharing_browser.dart +++ b/app/lib/widget/sharing_browser.dart @@ -251,9 +251,8 @@ class _SharingBrowserState extends State { } } - Future _onAlbumShareItemTap( - BuildContext context, ListSharingAlbum share) { - return album_browser_util.push(context, widget.account, share.album); + void _onAlbumShareItemTap(BuildContext context, ListSharingAlbum share) { + album_browser_util.push(context, widget.account, share.album); } void _transformItems(List items) { From 3ccf302553c3a27d30faa8bd38fc1573ae066de8 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 18 Apr 2023 00:15:29 +0800 Subject: [PATCH 07/67] Regression: set/unset album cover --- .../controller/collections_controller.dart | 4 + app/lib/entity/album.dart | 9 +- app/lib/entity/album/cover_provider.dart | 13 +- app/lib/entity/album/cover_provider.g.dart | 2 +- app/lib/entity/album/upgrader.dart | 49 +++++ app/lib/entity/album/upgrader.g.dart | 7 + app/lib/entity/collection.dart | 6 + app/lib/entity/collection/adapter.dart | 13 +- app/lib/entity/collection/adapter/album.dart | 37 +++- .../collection/adapter/location_group.dart | 4 + .../entity/collection/adapter/nc_album.dart | 15 +- app/lib/entity/collection/adapter/person.dart | 4 + .../collection/adapter/read_only_adapter.dart | 9 +- app/lib/entity/collection/adapter/tag.dart | 4 + .../collection/content_provider/album.dart | 9 + app/lib/or_null.dart | 8 + app/lib/or_null.g.dart | 14 ++ app/lib/use_case/album/edit_album.dart | 17 +- .../use_case/collection/edit_collection.dart | 5 + app/lib/widget/album_browser.dart | 3 +- app/lib/widget/collection_browser.dart | 29 ++- app/lib/widget/collection_browser.g.dart | 7 + .../widget/collection_browser/app_bar.dart | 129 +++++++----- app/lib/widget/collection_browser/bloc.dart | 75 ++++--- .../collection_browser/state_event.dart | 8 + app/lib/widget/smart_album_browser.dart | 3 +- app/lib/widget/viewer.dart | 95 +++++---- app/lib/widget/viewer_detail_pane.dart | 95 ++++----- app/test/entity/album_test.dart | 198 ++++++++++++++++++ 29 files changed, 653 insertions(+), 218 deletions(-) create mode 100644 app/lib/or_null.g.dart diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 52d7cdbf..246e6aad 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -9,6 +9,8 @@ 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/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.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'; @@ -157,6 +159,7 @@ class CollectionsController { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { try { final c = await _mutex.protect(() async { @@ -166,6 +169,7 @@ class CollectionsController { name: name, items: items, itemSort: itemSort, + cover: cover, ); }); _updateCollection(c, items); diff --git a/app/lib/entity/album.dart b/app/lib/entity/album.dart index e85835a1..629c03b3 100644 --- a/app/lib/entity/album.dart +++ b/app/lib/entity/album.dart @@ -94,6 +94,13 @@ class Album with EquatableMixin { return null; } } + if (jsonVersion < 9) { + result = upgraderFactory?.buildV8()?.call(result); + if (result == null) { + _log.info("[fromJson] Version $jsonVersion not compatible"); + return null; + } + } if (jsonVersion > version) { _log.warning( "[fromJson] Reading album with newer version: $jsonVersion > $version"); @@ -217,7 +224,7 @@ class Album with EquatableMixin { final int savedVersion; /// versioning of this class, use to upgrade old persisted album - static const version = 8; + static const version = 9; static final _log = _$AlbumNpLog.log; } diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index 55e05d73..6afd7645 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -122,13 +122,14 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { /// Cover picked by user @toString class AlbumManualCoverProvider extends AlbumCoverProvider { - AlbumManualCoverProvider({ + const AlbumManualCoverProvider({ required this.coverFile, }); factory AlbumManualCoverProvider.fromJson(JsonObj json) { return AlbumManualCoverProvider( - coverFile: File.fromJson(json["coverFile"].cast()), + coverFile: + FileDescriptor.fromJson(json["coverFile"].cast()), ); } @@ -136,21 +137,21 @@ class AlbumManualCoverProvider extends AlbumCoverProvider { String toString() => _$toString(); @override - getCover(Album album) => coverFile; + FileDescriptor? getCover(Album album) => coverFile; @override - get props => [ + List get props => [ coverFile, ]; @override - _toContentJson() { + JsonObj _toContentJson() { return { "coverFile": coverFile.toJson(), }; } - final File coverFile; + final FileDescriptor coverFile; static const _type = "manual"; } diff --git a/app/lib/entity/album/cover_provider.g.dart b/app/lib/entity/album/cover_provider.g.dart index a3adc55a..dcfd9a76 100644 --- a/app/lib/entity/album/cover_provider.g.dart +++ b/app/lib/entity/album/cover_provider.g.dart @@ -27,7 +27,7 @@ extension _$AlbumAutoCoverProviderToString on AlbumAutoCoverProvider { extension _$AlbumManualCoverProviderToString on AlbumManualCoverProvider { String _$toString() { // ignore: unnecessary_string_interpolations - return "AlbumManualCoverProvider {coverFile: ${coverFile.path}}"; + return "AlbumManualCoverProvider {coverFile: ${coverFile.fdPath}}"; } } diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index 92ebd5ff..f98af981 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -1,8 +1,10 @@ +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/object_extension.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; @@ -238,6 +240,49 @@ class AlbumUpgraderV7 implements AlbumUpgrader { final String? logFilePath; } +/// Upgrade v8 Album to v9 +@npLog +class AlbumUpgraderV8 implements AlbumUpgrader { + const AlbumUpgraderV8({ + this.logFilePath, + }); + + @override + JsonObj? call(JsonObj json) { + _log.fine("[call] Upgrade v8 Album for file: $logFilePath"); + final result = JsonObj.from(json); + try { + if (result["coverProvider"]["type"] != "manual") { + return result; + } + final content = (result["coverProvider"]["content"]["coverFile"] as Map) + .cast(); + final fd = { + "fdPath": content["path"], + "fdId": content["fileId"], + "fdMime": content["contentType"], + "fdIsArchived": content["isArchived"] ?? false, + "fdIsFavorite": content["isFavorite"] ?? false, + "fdDateTime": content["overrideDateTime"] ?? + (content["metadata"]?["exif"]?["DateTimeOriginal"] as String?)?.run( + (d) => + Exif.dateTimeFormat.parse(d).toUtc().toIso8601String()) ?? + content["lastModified"] ?? + clock.now().toUtc().toIso8601String(), + }; + result["coverProvider"]["content"]["coverFile"] = fd; + } catch (e, stackTrace) { + // this upgrade is not a must, if it failed then just leave it and it'll + // be upgraded the next time the album is saved + _log.shout("[call] Failed while upgrade", e, stackTrace); + } + return result; + } + + /// File path for logging only + final String? logFilePath; +} + abstract class AlbumUpgraderFactory { const AlbumUpgraderFactory(); @@ -248,6 +293,7 @@ abstract class AlbumUpgraderFactory { AlbumUpgraderV5? buildV5(); AlbumUpgraderV6? buildV6(); AlbumUpgraderV7? buildV7(); + AlbumUpgraderV8? buildV8(); } class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory { @@ -282,6 +328,9 @@ class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory { @override buildV7() => AlbumUpgraderV7(logFilePath: logFilePath); + @override + AlbumUpgraderV8? buildV8() => AlbumUpgraderV8(logFilePath: logFilePath); + final Account account; final File? albumFile; diff --git a/app/lib/entity/album/upgrader.g.dart b/app/lib/entity/album/upgrader.g.dart index 29011f64..bcc45112 100644 --- a/app/lib/entity/album/upgrader.g.dart +++ b/app/lib/entity/album/upgrader.g.dart @@ -54,3 +54,10 @@ extension _$AlbumUpgraderV7NpLog on AlbumUpgraderV7 { static final log = Logger("entity.album.upgrader.AlbumUpgraderV7"); } + +extension _$AlbumUpgraderV8NpLog on AlbumUpgraderV8 { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.album.upgrader.AlbumUpgraderV8"); +} diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index 4c205053..ddec018e 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -60,6 +60,8 @@ enum CollectionCapability { rename, // text labels labelItem, + // set the cover image + manualCover, } /// Provide the actual content of a collection @@ -80,6 +82,10 @@ abstract class CollectionContentProvider { DateTime get lastModified; /// Return the capabilities of the collection + /// + /// Notice that the capabilities returned here represent all the capabilities + /// that this implementation supports. In practice there may be extra runtime + /// requirements that mask some of them (e.g., user permissions) List get capabilities; /// Return the sort type diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index 343291da..5adc9596 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -16,6 +16,7 @@ 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:nc_photos/or_null.dart'; import 'package:np_common/type.dart'; abstract class CollectionAdapter { @@ -51,13 +52,11 @@ abstract class CollectionAdapter { }); /// 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, + OrNull? cover, }); /// Remove [items] from this collection and return the removed count @@ -70,10 +69,16 @@ abstract class CollectionAdapter { /// Convert a [NewCollectionItem] to an adapted one Future adaptToNewItem(NewCollectionItem original); - bool isItemsRemovable(List items); + bool isItemRemovable(CollectionItem item); /// Remove this collection Future remove(); + + /// Return if this capability is allowed + bool isPermitted(CollectionCapability capability); + + /// Return if the cover of this collection has been manually set + bool isManualCover(); } abstract class CollectionItemAdapter { diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 26cc76c6..94d5c16e 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -3,6 +3,7 @@ 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/cover_provider.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/collection.dart'; @@ -17,6 +18,7 @@ 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/or_null.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'; @@ -75,8 +77,9 @@ class CollectionAlbumAdapter implements CollectionAdapter { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { - assert(name != null || items != null || itemSort != null); + assert(name != null || items != null || itemSort != null || cover != null); final newItems = items?.run((items) => items .map((e) { if (e is AlbumAdaptedCollectionItem) { @@ -101,6 +104,7 @@ class CollectionAlbumAdapter implements CollectionAdapter { name: name, items: newItems, itemSort: itemSort, + cover: cover, ); return collection.copyWith( name: name, @@ -185,18 +189,39 @@ class CollectionAlbumAdapter implements CollectionAdapter { } @override - bool isItemsRemovable(List items) { - if (_provider.album.albumFile!.isOwned(account.userId)) { + bool isItemRemovable(CollectionItem item) { + if (_provider.album.provider is! AlbumStaticProvider) { + return false; + } + if (_provider.album.albumFile?.isOwned(account.userId) == true) { return true; } - return items - .whereType() - .any((e) => e.albumItem.addedBy == account.userId); + if (item is! AlbumAdaptedCollectionItem) { + _log.warning("[isItemRemovable] Unknown item type: ${item.runtimeType}"); + return true; + } + return item.albumItem.addedBy == account.userId; } @override Future remove() => RemoveAlbum(_c)(account, _provider.album); + @override + bool isPermitted(CollectionCapability capability) { + if (!_provider.capabilities.contains(capability)) { + return false; + } + if (_provider.album.albumFile?.isOwned(account.userId) == true) { + return true; + } else { + return _provider.guestCapabilities.contains(capability); + } + } + + @override + bool isManualCover() => + _provider.album.coverProvider is AlbumManualCoverProvider; + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/location_group.dart b/app/lib/entity/collection/adapter/location_group.dart index b06fd8cf..3f012101 100644 --- a/app/lib/entity/collection/adapter/location_group.dart +++ b/app/lib/entity/collection/adapter/location_group.dart @@ -48,6 +48,10 @@ class CollectionLocationGroupAdapter throw UnsupportedError("Operation not supported"); } + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index dde6bdae..33f92524 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -13,6 +13,7 @@ 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/or_null.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'; @@ -75,9 +76,10 @@ class CollectionNcAlbumAdapter implements CollectionAdapter { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { assert(name != null); - if (items != null || itemSort != null) { + if (items != null || itemSort != null || cover != null) { _log.warning( "[edit] Nextcloud album does not support editing item or sort"); } @@ -131,13 +133,18 @@ class CollectionNcAlbumAdapter implements CollectionAdapter { } @override - bool isItemsRemovable(List items) { - return true; - } + bool isItemRemovable(CollectionItem item) => true; @override Future remove() => RemoveNcAlbum(_c)(account, _provider.album); + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + + @override + bool isManualCover() => false; + Future _syncRemote() async { final remote = await ListNcAlbum(_c)(account).last; return remote.firstWhere((e) => e.compareIdentity(_provider.album)); diff --git a/app/lib/entity/collection/adapter/person.dart b/app/lib/entity/collection/adapter/person.dart index 0d9ee9c0..926b202d 100644 --- a/app/lib/entity/collection/adapter/person.dart +++ b/app/lib/entity/collection/adapter/person.dart @@ -50,6 +50,10 @@ class CollectionPersonAdapter throw UnsupportedError("Operation not supported"); } + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/read_only_adapter.dart index af68d849..80b1f96d 100644 --- a/app/lib/entity/collection/adapter/read_only_adapter.dart +++ b/app/lib/entity/collection/adapter/read_only_adapter.dart @@ -4,6 +4,7 @@ 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:nc_photos/or_null.dart'; import 'package:np_common/type.dart'; /// A read-only collection that does not support modifying its items @@ -22,6 +23,7 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) { throw UnsupportedError("Operation not supported"); } @@ -36,7 +38,8 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { } @override - bool isItemsRemovable(List items) { - return false; - } + bool isItemRemovable(CollectionItem item) => false; + + @override + bool isManualCover() => false; } diff --git a/app/lib/entity/collection/adapter/tag.dart b/app/lib/entity/collection/adapter/tag.dart index e7a85432..29135ac5 100644 --- a/app/lib/entity/collection/adapter/tag.dart +++ b/app/lib/entity/collection/adapter/tag.dart @@ -37,6 +37,10 @@ class CollectionTagAdapter throw UnsupportedError("Operation not supported"); } + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index f27845be..0578dd70 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -44,6 +44,7 @@ class CollectionAlbumProvider implements CollectionContentProvider { List get capabilities => [ CollectionCapability.sort, CollectionCapability.rename, + CollectionCapability.manualCover, if (album.provider is AlbumStaticProvider) ...[ CollectionCapability.manualItem, CollectionCapability.manualSort, @@ -51,6 +52,14 @@ class CollectionAlbumProvider implements CollectionContentProvider { ], ]; + /// Capabilities when this album is shared to this user by someone else + List get guestCapabilities => [ + if (album.provider is AlbumStaticProvider) ...[ + CollectionCapability.manualItem, + CollectionCapability.labelItem, + ], + ]; + @override CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort(); diff --git a/app/lib/or_null.dart b/app/lib/or_null.dart index 2f1d4d4b..fb3dc552 100644 --- a/app/lib/or_null.dart +++ b/app/lib/or_null.dart @@ -1,4 +1,9 @@ +import 'package:to_string/to_string.dart'; + +part 'or_null.g.dart'; + /// To hold optional arguments that themselves could be null +@toString class OrNull { OrNull(this.obj); @@ -6,5 +11,8 @@ class OrNull { /// null, false will still be returned static bool isSetNull(OrNull? x) => x != null && x.obj == null; + @override + String toString() => _$toString(); + final T? obj; } diff --git a/app/lib/or_null.g.dart b/app/lib/or_null.g.dart new file mode 100644 index 00000000..4f251ca3 --- /dev/null +++ b/app/lib/or_null.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'or_null.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$OrNullToString on OrNull { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "OrNull {obj: $obj}"; + } +} diff --git a/app/lib/use_case/album/edit_album.dart b/app/lib/use_case/album/edit_album.dart index 2ce141e0..7f250087 100644 --- a/app/lib/use_case/album/edit_album.dart +++ b/app/lib/use_case/album/edit_album.dart @@ -2,10 +2,13 @@ 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/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/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -22,9 +25,10 @@ class EditAlbum { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { _log.info( - "[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort"); + "[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort, cover: $cover"); var newAlbum = album; if (name != null) { newAlbum = newAlbum.copyWith(name: name); @@ -43,6 +47,17 @@ class EditAlbum { sortProvider: AlbumSortProvider.fromCollectionItemSort(itemSort), ); } + if (cover != null) { + if (cover.obj == null) { + newAlbum = newAlbum.copyWith( + coverProvider: AlbumAutoCoverProvider(), + ); + } else { + newAlbum = newAlbum.copyWith( + coverProvider: AlbumManualCoverProvider(coverFile: cover.obj!), + ); + } + } if (identical(newAlbum, album)) { return album; } diff --git a/app/lib/use_case/collection/edit_collection.dart b/app/lib/use_case/collection/edit_collection.dart index 19d584b7..15d11a07 100644 --- a/app/lib/use_case/collection/edit_collection.dart +++ b/app/lib/use_case/collection/edit_collection.dart @@ -4,6 +4,8 @@ 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:nc_photos/or_null.dart'; class EditCollection { const EditCollection(this._c); @@ -15,6 +17,7 @@ class EditCollection { /// - Rename (set [name]) /// - Add text label(s) (set [items]) /// - Sort [items] (set [items] and/or [itemSort]) + /// - Set album [cover] /// /// \* To add files to a collection, use [AddFileToCollection] instead Future call( @@ -23,11 +26,13 @@ class EditCollection { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) => CollectionAdapter.of(_c, account, collection).edit( name: name, items: items, itemSort: itemSort, + cover: cover, ); final DiContainer _c; diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index 13cb10c9..0d1227c3 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -353,8 +353,7 @@ class _AlbumBrowserState extends State } } Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex, - album: _album)); + arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); } Future _onSharePressed(BuildContext context) async { diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 48929709..8698aa17 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -4,7 +4,6 @@ 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'; @@ -37,6 +36,7 @@ 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/or_null.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'; @@ -227,8 +227,10 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> if (!state.isEditMode) { return const _ContentList(); } else { - if (state.collection.capabilities - .contains(CollectionCapability.manualSort)) { + if (context + .read<_Bloc>() + .isCollectionCapabilityPermitted( + CollectionCapability.manualSort)) { return const _EditContentList(); } else { return const _UnmodifiableEditContentList(); @@ -314,16 +316,18 @@ class _ContentList extends StatelessWidget { @override Widget build(BuildContext context) { + final bloc = context.read<_Bloc>(); return StreamBuilder( stream: context.read().albumBrowserZoomLevel, initialData: context.read().albumBrowserZoomLevel.value, builder: (_, zoomLevel) { if (zoomLevel.hasError) { - context.read<_Bloc>().add( + bloc.add( _SetMessage(L10n.global().writePreferenceFailureNotification)); } return _BlocBuilder( buildWhen: (previous, current) => + previous.collection != current.collection || previous.transformedItems != current.transformedItems || previous.selectedItems != current.selectedItems, builder: (context, state) { @@ -336,9 +340,7 @@ class _ContentList extends StatelessWidget { staggeredTileBuilder: (_, item) => item.staggeredTile, selectedItems: state.selectedItems, onSelectionChange: (_, selected) { - context - .read<_Bloc>() - .add(_SetSelectedItems(items: selected.cast())); + bloc.add(_SetSelectedItems(items: selected.cast())); }, onItemTap: (context, index, _) { final actualIndex = index - @@ -349,12 +351,19 @@ class _ContentList extends StatelessWidget { Navigator.of(context).pushNamed( Viewer.routeName, arguments: ViewerArguments( - context.read<_Bloc>().account, + bloc.account, state.transformedItems .whereType<_FileItem>() .map((e) => e.file) .toList(), actualIndex, + fromCollection: ViewerCollectionData( + state.collection, + state.transformedItems + .whereType<_ActualItem>() + .map((e) => e.original) + .toList(), + ), ), ); }, @@ -383,8 +392,8 @@ class _EditContentList extends StatelessWidget { buildWhen: (previous, current) => previous.editTransformedItems != current.editTransformedItems, builder: (context, state) { - if (state.collection.capabilities - .contains(CollectionCapability.manualSort)) { + if (context.read<_Bloc>().isCollectionCapabilityPermitted( + CollectionCapability.manualSort)) { return DraggableItemList<_Item>( maxCrossAxisExtent: photo_list_util .getThumbSize(zoomLevel.requireData) diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index 75e7b253..46e3f96d 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -216,6 +216,13 @@ extension _$_CancelEditToString on _CancelEdit { } } +extension _$_UnsetCoverToString on _UnsetCover { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_UnsetCover {}"; + } +} + extension _$_SetSelectedItemsToString on _SetSelectedItems { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 938d3098..b69d46d2 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -5,58 +5,69 @@ class _AppBar extends StatelessWidget { @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, + final c = KiwiContainer().resolve(); + return _BlocBuilder( + buildWhen: (previous, current) => + previous.items != current.items || + previous.collection != current.collection, + builder: (context, state) { + final bloc = context.read<_Bloc>(); + final adapter = CollectionAdapter.of(c, bloc.account, state.collection); + final canRename = adapter.isPermitted(CollectionCapability.rename); + final canManualCover = + adapter.isPermitted(CollectionCapability.manualCover); + + final actions = [ + ZoomMenuButton( + initialZoom: 0, + minZoom: 0, + maxZoom: 2, + onZoomChanged: (value) { + context.read().setAlbumBrowserZoomLevel(value); + }, + ), + ]; + if (state.items.isNotEmpty || canRename) { + actions.add(PopupMenuButton<_MenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (_) => [ + if (canRename) + PopupMenuItem( + value: _MenuOption.edit, + child: Text(L10n.global().editTooltip), + ), + if (canManualCover && adapter.isManualCover()) + PopupMenuItem( + value: _MenuOption.unsetCover, + child: Text(L10n.global().unsetAlbumCoverTooltip), + ), + if (state.items.isNotEmpty) + PopupMenuItem( + value: _MenuOption.download, + child: Text(L10n.global().downloadTooltip), + ), + ], + onSelected: (option) { + _onMenuSelected(context, option); + }, + )); + } + + return SliverAppBar( + floating: true, + expandedHeight: 160, + flexibleSpace: FlexibleSpaceBar( + background: const _AppBarCover(), + title: 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)) - _BlocBuilder( - buildWhen: (previous, current) => previous.items != current.items, - builder: (context, state) => PopupMenuButton<_MenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) { - return [ - if (capabilities.contains(CollectionCapability.rename)) - PopupMenuItem( - value: _MenuOption.edit, - child: Text(L10n.global().editTooltip), - ), - if (state.items.isNotEmpty) - PopupMenuItem( - value: _MenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - ]; - }, - onSelected: (option) { - _onMenuSelected(context, option); - }, - ), - ), - ], + actions: actions, + ); + }, ); } @@ -65,6 +76,9 @@ class _AppBar extends StatelessWidget { case _MenuOption.edit: context.read<_Bloc>().add(const _BeginEdit()); break; + case _MenuOption.unsetCover: + context.read<_Bloc>().add(const _UnsetCover()); + break; case _MenuOption.download: context.read<_Bloc>().add(const _Download()); break; @@ -145,8 +159,8 @@ class _SelectionAppBar extends StatelessWidget { PopupMenuButton<_SelectionMenuOption>( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, itemBuilder: (context) => [ - if (state.collection.capabilities - .contains(CollectionCapability.manualItem) && + if (context.read<_Bloc>().isCollectionCapabilityPermitted( + CollectionCapability.manualItem) && state.isSelectionRemovable) PopupMenuItem( value: _SelectionMenuOption.removeFromAlbum, @@ -237,7 +251,11 @@ class _EditAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - final capabilities = context.read<_Bloc>().state.collection.capabilities; + final capabilitiesAdapter = CollectionAdapter.of( + KiwiContainer().resolve(), + context.read<_Bloc>().account, + context.read<_Bloc>().state.collection, + ); return SliverAppBar( floating: true, expandedHeight: 160, @@ -274,13 +292,13 @@ class _EditAppBar extends StatelessWidget { }, ), actions: [ - if (capabilities.contains(CollectionCapability.labelItem)) + if (capabilitiesAdapter.isPermitted(CollectionCapability.labelItem)) IconButton( icon: const Icon(Icons.text_fields), tooltip: L10n.global().albumAddTextTooltip, onPressed: () => _onAddTextPressed(context), ), - if (capabilities.contains(CollectionCapability.sort)) + if (capabilitiesAdapter.isPermitted(CollectionCapability.sort)) IconButton( icon: const Icon(Icons.sort_by_alpha), tooltip: L10n.global().sortTooltip, @@ -354,6 +372,7 @@ class _EditAppBar extends StatelessWidget { enum _MenuOption { edit, + unsetCover, download, } diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index e101948e..e6a6eb40 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -29,6 +29,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_DoneEdit>(_onDoneEdit, transformer: concurrent()); on<_CancelEdit>(_onCancelEdit); + on<_UnsetCover>(_onUnsetCover); + on<_SetSelectedItems>(_onSetSelectedItems); on<_DownloadSelectedItems>(_onDownloadSelectedItems); on<_AddSelectedItemsToCollection>(_onAddSelectedItemsToCollection); @@ -66,12 +68,18 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { return super.close(); } + bool isCollectionCapabilityPermitted(CollectionCapability capability) { + return CollectionAdapter.of(_c, account, state.collection) + .isPermitted(capability); + } + @override String get tag => _log.fullName; void _onUpdateCollection(_UpdateCollection ev, Emitter<_State> emit) { _log.info("$ev"); emit(state.copyWith(collection: ev.collection)); + _updateCover(emit); } Future _onLoad(_LoadItems ev, Emitter<_State> emit) { @@ -94,15 +102,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { 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); + emit(state.copyWith(transformedItems: result.transformed)); + _updateCover(emit); } void _onDownload(_Download ev, Emitter<_State> emit) { @@ -128,8 +129,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { void _onAddLabelToCollection(_AddLabelToCollection ev, Emitter<_State> emit) { _log.info("$ev"); - assert( - state.collection.capabilities.contains(CollectionCapability.labelItem)); + assert(isCollectionCapabilityPermitted(CollectionCapability.labelItem)); emit(state.copyWith( editItems: [ NewCollectionLabelItem(ev.label, clock.now().toUtc()), @@ -149,8 +149,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { void _onEditManualSort(_EditManualSort ev, Emitter<_State> emit) { _log.info("$ev"); - assert(state.collection.capabilities - .contains(CollectionCapability.manualSort)); + assert(isCollectionCapabilityPermitted(CollectionCapability.manualSort)); emit(state.copyWith( editSort: CollectionItemSort.manual, editItems: @@ -204,15 +203,20 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { )); } + void _onUnsetCover(_UnsetCover ev, Emitter<_State> emit) { + _log.info("$ev"); + collectionsController.edit(state.collection, cover: OrNull(null)); + } + void _onSetSelectedItems(_SetSelectedItems ev, Emitter<_State> emit) { _log.info("$ev"); + final adapter = CollectionAdapter.of(_c, account, state.collection); emit(state.copyWith( selectedItems: ev.items, - isSelectionRemovable: CollectionAdapter.of(_c, account, state.collection) - .isItemsRemovable(ev.items - .whereType<_ActualItem>() - .map((e) => e.original) - .toList()), + isSelectionRemovable: ev.items + .whereType<_ActualItem>() + .map((e) => e.original) + .any(adapter.isItemRemovable), )); } @@ -403,23 +407,20 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { ); } - String? _getCoverUrlOnNewItem(List sortedItems) { + String? _getCoverUrlByItems() { 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; + state.transformedItems.whereType<_FileItem>().first.file; + return api_util.getFilePreviewUrlByFileId( + account, + firstFile.fdId, + width: k.coverSize, + height: k.coverSize, + isKeepAspectRatio: false, + ); + } catch (_) { + return null; + } } static String? _getCoverUrl(Collection collection) { @@ -430,6 +431,14 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { } } + void _updateCover(Emitter<_State> emit) { + var coverUrl = _getCoverUrl(state.collection); + coverUrl ??= _getCoverUrlByItems(); + if (coverUrl != state.coverUrl) { + emit(state.copyWith(coverUrl: coverUrl)); + } + } + final DiContainer _c; final Account account; final CollectionsController collectionsController; diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index 4fbf463f..c92b9ada 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -188,6 +188,14 @@ class _CancelEdit implements _Event { String toString() => _$toString(); } +@toString +class _UnsetCover implements _Event { + const _UnsetCover(); + + @override + String toString() => _$toString(); +} + /// Set the currently selected items @toString class _SetSelectedItems implements _Event { diff --git a/app/lib/widget/smart_album_browser.dart b/app/lib/widget/smart_album_browser.dart index 4c071b63..4ddf18be 100644 --- a/app/lib/widget/smart_album_browser.dart +++ b/app/lib/widget/smart_album_browser.dart @@ -206,8 +206,7 @@ class _SmartAlbumBrowserState extends State } } Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex, - album: widget.album)); + arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); } void _onDownloadPressed() { diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 9c1b02bf..5e0ccbfb 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -5,25 +5,29 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.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/controller/account_controller.dart'; +import 'package:nc_photos/controller/collection_items_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.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/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/flutter_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/notified_action.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/share_handler.dart'; +import 'package:nc_photos/snack_bar_manager.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/update_property.dart'; import 'package:nc_photos/widget/disposable.dart'; @@ -44,18 +48,25 @@ import 'package:np_codegen/np_codegen.dart'; part 'viewer.g.dart'; +class ViewerCollectionData { + const ViewerCollectionData(this.collection, this.items); + + final Collection collection; + final List items; +} + class ViewerArguments { - ViewerArguments( + const ViewerArguments( this.account, this.streamFiles, this.startIndex, { - this.album, + this.fromCollection, }); final Account account; final List streamFiles; final int startIndex; - final Album? album; + final ViewerCollectionData? fromCollection; } class Viewer extends StatefulWidget { @@ -73,7 +84,7 @@ class Viewer extends StatefulWidget { required this.account, required this.streamFiles, required this.startIndex, - this.album, + this.fromCollection, }) : super(key: key); Viewer.fromArgs(ViewerArguments args, {Key? key}) @@ -82,7 +93,7 @@ class Viewer extends StatefulWidget { account: args.account, streamFiles: args.streamFiles, startIndex: args.startIndex, - album: args.album, + fromCollection: args.fromCollection, ); @override @@ -92,8 +103,8 @@ class Viewer extends StatefulWidget { final List streamFiles; final int startIndex; - /// The album these files belongs to, or null - final Album? album; + /// Data of the collection these files belongs to, or null + final ViewerCollectionData? fromCollection; } @npLog @@ -304,9 +315,11 @@ class _ViewerState extends State child: ViewerDetailPane( account: widget.account, fd: _streamFilesView[index], - album: widget.album, - onRemoveFromAlbumPressed: - _onRemoveFromAlbumPressed, + fromCollection: widget.fromCollection?.run( + (d) => ViewerSingleCollectionData( + d.collection, d.items[index])), + onRemoveFromCollectionPressed: + _onRemoveFromCollectionPressed, onArchivePressed: _onArchivePressed, onUnarchivePressed: _onUnarchivePressed, onSlideshowPressed: _onSlideshowPressed, @@ -666,30 +679,27 @@ class _ViewerState extends State _removeCurrentItemFromStream(context, index); } - void _onRemoveFromAlbumPressed(BuildContext context) { - assert(widget.album!.provider is AlbumStaticProvider); + Future _onRemoveFromCollectionPressed(BuildContext context) async { + assert(CollectionAdapter.of(KiwiContainer().resolve(), + widget.account, widget.fromCollection!.collection) + .isPermitted(CollectionCapability.manualItem)); final index = _viewerController.currentPage; - final c = KiwiContainer().resolve(); final file = _streamFilesView[index]; - _log.info("[_onRemoveFromAlbumPressed] Remove file: ${file.fdPath}"); - NotifiedAction( - () async { - final selectedFile = - (await InflateFileDescriptor(c)(widget.account, [file])).first; - final thisItem = AlbumStaticProvider.of(widget.album!) - .items - .whereType() - .firstWhere((e) => e.file.compareServerIdentity(selectedFile)); - await RemoveFromAlbum(KiwiContainer().resolve())( - widget.account, widget.album!, [thisItem]); - }, - null, - L10n.global().removeSelectedFromAlbumSuccessNotification(1), - failureText: L10n.global().removeSelectedFromAlbumFailureNotification, - ).call().catchError((e, stackTrace) { - _log.shout("[_onRemoveFromAlbumPressed] Failed while updating album", e, - stackTrace); - }); + _log.info("[_onRemoveFromCollectionPressed] Remove file: ${file.fdPath}"); + try { + final itemsController = _findCollectionItemsController(context); + final item = itemsController.stream.value.items + .whereType() + .firstWhere((i) => i.file.compareServerIdentity(file)); + await itemsController.removeItems([item]); + } catch (e, stackTrace) { + _log.shout("[_onRemoveFromCollectionPressed] Failed while updating album", + e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().removeSelectedFromAlbumFailureNotification), + duration: k.snackBarDurationNormal, + )); + } _removeCurrentItemFromStream(context, index); } @@ -829,6 +839,19 @@ class _ViewerState extends State _isClosingDetailPane = false; } + CollectionItemsController _findCollectionItemsController( + BuildContext context) { + return context + .read() + .collectionsController + .stream + .value + .data + .firstWhere((d) => + d.collection.compareIdentity(widget.fromCollection!.collection)) + .controller; + } + bool _canSwitchPage() => !_isZoomed; bool _canOpenDetailPane() => !_isZoomed; bool _canZoom() => !_isDetailPaneActive; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index f5f9867c..761234a2 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -2,32 +2,32 @@ import 'dart:async'; import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.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/controller/account_controller.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/double_extension.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/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/exif_extension.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/location_util.dart' as location_util; -import 'package:nc_photos/notified_action.dart'; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/list_file_tag.dart'; -import 'package:nc_photos/use_case/update_album.dart'; 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'; @@ -41,13 +41,20 @@ import 'package:tuple/tuple.dart'; part 'viewer_detail_pane.g.dart'; +class ViewerSingleCollectionData { + const ViewerSingleCollectionData(this.collection, this.item); + + final Collection collection; + final CollectionItem item; +} + class ViewerDetailPane extends StatefulWidget { const ViewerDetailPane({ Key? key, required this.account, required this.fd, - this.album, - required this.onRemoveFromAlbumPressed, + this.fromCollection, + required this.onRemoveFromCollectionPressed, required this.onArchivePressed, required this.onUnarchivePressed, this.onSlideshowPressed, @@ -59,10 +66,10 @@ class ViewerDetailPane extends StatefulWidget { final Account account; final FileDescriptor fd; - /// The album this file belongs to, or null - final Album? album; + /// Data of the collection this file belongs to, or null + final ViewerSingleCollectionData? fromCollection; - final void Function(BuildContext context) onRemoveFromAlbumPressed; + final void Function(BuildContext context) onRemoveFromCollectionPressed; final void Function(BuildContext context) onArchivePressed; final void Function(BuildContext context) onUnarchivePressed; final VoidCallback? onSlideshowPressed; @@ -148,11 +155,10 @@ class _ViewerDetailPaneState extends State { _DetailPaneButton( icon: Icons.remove_outlined, label: L10n.global().removeFromAlbumTooltip, - onPressed: () => widget.onRemoveFromAlbumPressed(context), + onPressed: () => + widget.onRemoveFromCollectionPressed(context), ), - if (widget.album != null && - widget.album!.albumFile?.isOwned(widget.account.userId) == - true) + if (_canSetCover) _DetailPaneButton( icon: Icons.photo_album_outlined, label: L10n.global().useAsAlbumCoverTooltip, @@ -384,27 +390,21 @@ class _ViewerDetailPaneState extends State { Future _onSetAlbumCoverPressed(BuildContext context) async { assert(_file != null); - assert(widget.album != null); + assert(widget.fromCollection != null); _log.info( - "[_onSetAlbumCoverPressed] Set '${widget.fd.fdPath}' as album cover for '${widget.album!.name}'"); + "[_onSetAlbumCoverPressed] Set '${widget.fd.fdPath}' as album cover for '${widget.fromCollection!.collection.name}'"); try { - await NotifiedAction( - () async { - await UpdateAlbum(_c.albumRepo)( - widget.account, - widget.album!.copyWith( - coverProvider: AlbumManualCoverProvider( - coverFile: _file!, - ), - )); - }, - L10n.global().setAlbumCoverProcessingNotification, - L10n.global().setAlbumCoverSuccessNotification, - failureText: L10n.global().setAlbumCoverFailureNotification, - )(); + await context.read().collectionsController.edit( + widget.fromCollection!.collection, + cover: OrNull(_file!), + ); } catch (e, stackTrace) { _log.shout("[_onSetAlbumCoverPressed] Failed while updating album", e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().setAlbumCoverFailureNotification), + duration: k.snackBarDurationNormal, + )); } } @@ -459,27 +459,6 @@ class _ViewerDetailPaneState extends State { }); } - bool _checkCanRemoveFromAlbum() { - if (widget.album == null || - widget.album!.provider is! AlbumStaticProvider) { - return false; - } - if (widget.album!.albumFile?.isOwned(widget.account.userId) == true) { - return true; - } - try { - final thisItem = AlbumStaticProvider.of(widget.album!) - .items - .whereType() - .firstWhere( - (element) => element.file.compareServerIdentity(widget.fd)); - if (thisItem.addedBy == widget.account.userId) { - return true; - } - } catch (_) {} - return false; - } - late final DiContainer _c; File? _file; @@ -495,9 +474,17 @@ class _ViewerDetailPaneState extends State { final _tags = []; - late final bool _canRemoveFromAlbum = _checkCanRemoveFromAlbum(); - var _shouldBlockGpsMap = true; + + late final bool _canRemoveFromAlbum = widget.fromCollection?.run((d) => + CollectionAdapter.of(_c, widget.account, d.collection) + .isItemRemovable(widget.fromCollection!.item)) ?? + false; + + late final bool _canSetCover = widget.fromCollection?.run((d) => + CollectionAdapter.of(_c, widget.account, d.collection) + .isPermitted(CollectionCapability.manualCover)) ?? + false; } class _DetailPaneButton extends StatelessWidget { diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index d21c9771..5ef6cb85 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:intl/intl.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; @@ -1758,6 +1759,12 @@ void main() { }); }); }); + + group("AlbumUpgraderV8", () { + test("non manual cover", _upgradeV8NonManualCover); + test("manual cover", _upgradeV8ManualCover); + test("manual cover (exif time)", _upgradeV8ManualExifTime); + }); }); } @@ -1883,6 +1890,195 @@ void _toAppDbJsonShares() { }); } +void _upgradeV8NonManualCover() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "memory", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "memory", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8ManualCover() { + withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); +} + +void _upgradeV8ManualExifTime() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "metadata": { + "exif": { + "DateTimeOriginal": "2020:01:02 03:04:05", + }, + }, + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + // dart does not provide a way to mock timezone + "fdDateTime": DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(), + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { const _NullAlbumUpgraderFactory(); @@ -1900,4 +2096,6 @@ class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { buildV6() => null; @override buildV7() => null; + @override + AlbumUpgraderV8? buildV8() => null; } From 72bd5fa38e6a4ebcac73be24eb223b8e55a53071 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 18 Apr 2023 00:15:54 +0800 Subject: [PATCH 08/67] Fix DateTime incorrectly serialized in FileDescriptor --- app/lib/entity/file_descriptor.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/entity/file_descriptor.dart b/app/lib/entity/file_descriptor.dart index eb66bae6..ec4e28ec 100644 --- a/app/lib/entity/file_descriptor.dart +++ b/app/lib/entity/file_descriptor.dart @@ -30,7 +30,7 @@ class FileDescriptor with EquatableMixin { fdMime: json["fdMime"], fdIsArchived: json["fdIsArchived"], fdIsFavorite: json["fdIsFavorite"], - fdDateTime: json["fdDateTime"], + fdDateTime: DateTime.parse(json["fdDateTime"]), ); JsonObj toJson() => { @@ -39,7 +39,7 @@ class FileDescriptor with EquatableMixin { "fdMime": fdMime, "fdIsArchived": fdIsArchived, "fdIsFavorite": fdIsFavorite, - "fdDateTime": fdDateTime, + "fdDateTime": fdDateTime.toUtc().toIso8601String(), }; @override From 7bd33c844451ec699e3ef45f485851d974f5033e Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 18 Apr 2023 00:58:09 +0800 Subject: [PATCH 09/67] Migrate AlbumAutoCoverProvider to use FileDescriptor --- app/lib/entity/album/cover_provider.dart | 12 +- app/lib/entity/album/cover_provider.g.dart | 2 +- app/lib/entity/album/upgrader.dart | 47 +++-- app/lib/use_case/album/edit_album.dart | 2 +- app/lib/use_case/album/remove_from_album.dart | 2 +- app/lib/use_case/update_auto_album_cover.dart | 4 +- app/lib/widget/album_browser_mixin.dart | 2 +- app/lib/widget/album_importer.dart | 2 +- .../widget/new_collection_dialog/bloc.dart | 6 +- app/test/entity/album_test.dart | 166 +++++++++++++++--- app/test/use_case/remove_from_album_test.dart | 2 +- app/test/use_case/remove_test.dart | 8 +- 12 files changed, 191 insertions(+), 64 deletions(-) diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index 6afd7645..83c52a0b 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -65,7 +65,7 @@ abstract class AlbumCoverProvider with EquatableMixin { /// Cover selected automatically by us @toString class AlbumAutoCoverProvider extends AlbumCoverProvider { - AlbumAutoCoverProvider({ + const AlbumAutoCoverProvider({ this.coverFile, }); @@ -73,7 +73,7 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { return AlbumAutoCoverProvider( coverFile: json["coverFile"] == null ? null - : File.fromJson(json["coverFile"].cast()), + : FileDescriptor.fromJson(json["coverFile"].cast()), ); } @@ -81,7 +81,7 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { String toString() => _$toString(); @override - getCover(Album album) { + FileDescriptor? getCover(Album album) { if (coverFile == null) { try { // use the latest file as cover @@ -103,18 +103,18 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { } @override - get props => [ + List get props => [ coverFile, ]; @override - _toContentJson() { + JsonObj _toContentJson() { return { if (coverFile != null) "coverFile": coverFile!.toJson(), }; } - final File? coverFile; + final FileDescriptor? coverFile; static const _type = "auto"; } diff --git a/app/lib/entity/album/cover_provider.g.dart b/app/lib/entity/album/cover_provider.g.dart index dcfd9a76..d958ec48 100644 --- a/app/lib/entity/album/cover_provider.g.dart +++ b/app/lib/entity/album/cover_provider.g.dart @@ -20,7 +20,7 @@ extension _$AlbumCoverProviderNpLog on AlbumCoverProvider { extension _$AlbumAutoCoverProviderToString on AlbumAutoCoverProvider { String _$toString() { // ignore: unnecessary_string_interpolations - return "AlbumAutoCoverProvider {coverFile: ${coverFile == null ? null : "${coverFile!.path}"}}"; + return "AlbumAutoCoverProvider {coverFile: ${coverFile == null ? null : "${coverFile!.fdPath}"}}"; } } diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index f98af981..d1cb98ef 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -252,25 +252,23 @@ class AlbumUpgraderV8 implements AlbumUpgrader { _log.fine("[call] Upgrade v8 Album for file: $logFilePath"); final result = JsonObj.from(json); try { - if (result["coverProvider"]["type"] != "manual") { + if (result["coverProvider"]["type"] == "manual") { + final content = (result["coverProvider"]["content"]["coverFile"] as Map) + .cast(); + final fd = _fileJsonToFileDescriptorJson(content); + result["coverProvider"]["content"]["coverFile"] = fd; + } else if (result["coverProvider"]["type"] == "auto") { + final content = + (result["coverProvider"]["content"]["coverFile"] as Map?) + ?.cast(); + if (content != null) { + final fd = _fileJsonToFileDescriptorJson(content); + result["coverProvider"]["content"]["coverFile"] = fd; + } + return result; + } else { return result; } - final content = (result["coverProvider"]["content"]["coverFile"] as Map) - .cast(); - final fd = { - "fdPath": content["path"], - "fdId": content["fileId"], - "fdMime": content["contentType"], - "fdIsArchived": content["isArchived"] ?? false, - "fdIsFavorite": content["isFavorite"] ?? false, - "fdDateTime": content["overrideDateTime"] ?? - (content["metadata"]?["exif"]?["DateTimeOriginal"] as String?)?.run( - (d) => - Exif.dateTimeFormat.parse(d).toUtc().toIso8601String()) ?? - content["lastModified"] ?? - clock.now().toUtc().toIso8601String(), - }; - result["coverProvider"]["content"]["coverFile"] = fd; } catch (e, stackTrace) { // this upgrade is not a must, if it failed then just leave it and it'll // be upgraded the next time the album is saved @@ -279,6 +277,21 @@ class AlbumUpgraderV8 implements AlbumUpgrader { return result; } + static JsonObj _fileJsonToFileDescriptorJson(JsonObj json) { + return { + "fdPath": json["path"], + "fdId": json["fileId"], + "fdMime": json["contentType"], + "fdIsArchived": json["isArchived"] ?? false, + "fdIsFavorite": json["isFavorite"] ?? false, + "fdDateTime": json["overrideDateTime"] ?? + (json["metadata"]?["exif"]?["DateTimeOriginal"] as String?)?.run( + (d) => Exif.dateTimeFormat.parse(d).toUtc().toIso8601String()) ?? + json["lastModified"] ?? + clock.now().toUtc().toIso8601String(), + }; + } + /// File path for logging only final String? logFilePath; } diff --git a/app/lib/use_case/album/edit_album.dart b/app/lib/use_case/album/edit_album.dart index 7f250087..021a6282 100644 --- a/app/lib/use_case/album/edit_album.dart +++ b/app/lib/use_case/album/edit_album.dart @@ -50,7 +50,7 @@ class EditAlbum { if (cover != null) { if (cover.obj == null) { newAlbum = newAlbum.copyWith( - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), ); } else { newAlbum = newAlbum.copyWith( diff --git a/app/lib/use_case/album/remove_from_album.dart b/app/lib/use_case/album/remove_from_album.dart index 610a08d7..d9af6ad7 100644 --- a/app/lib/use_case/album/remove_from_album.dart +++ b/app/lib/use_case/album/remove_from_album.dart @@ -103,7 +103,7 @@ class RemoveFromAlbum { true) { // revert to auto cover so [UpdateAutoAlbumCover] can do its work newAlbum = newAlbum.copyWith( - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), ); isNeedUpdate = true; break; diff --git a/app/lib/use_case/update_auto_album_cover.dart b/app/lib/use_case/update_auto_album_cover.dart index 5f2f08b3..b51f287c 100644 --- a/app/lib/use_case/update_auto_album_cover.dart +++ b/app/lib/use_case/update_auto_album_cover.dart @@ -34,8 +34,8 @@ class UpdateAutoAlbumCover { Album _updateWithSortedItems(Album album, List sortedItems) { if (sortedItems.isEmpty) { - if (album.coverProvider != AlbumAutoCoverProvider()) { - return album.copyWith(coverProvider: AlbumAutoCoverProvider()); + if (album.coverProvider != const AlbumAutoCoverProvider()) { + return album.copyWith(coverProvider: const AlbumAutoCoverProvider()); } else { return album; } diff --git a/app/lib/widget/album_browser_mixin.dart b/app/lib/widget/album_browser_mixin.dart index b108e5d9..08e867c6 100644 --- a/app/lib/widget/album_browser_mixin.dart +++ b/app/lib/widget/album_browser_mixin.dart @@ -211,7 +211,7 @@ mixin AlbumBrowserMixin await UpdateAlbum(albumRepo)( account, album.copyWith( - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), )); }, L10n.global().unsetAlbumCoverProcessingNotification, diff --git a/app/lib/widget/album_importer.dart b/app/lib/widget/album_importer.dart index a54ad28f..fd36fb9e 100644 --- a/app/lib/widget/album_importer.dart +++ b/app/lib/widget/album_importer.dart @@ -245,7 +245,7 @@ class _AlbumImporterState extends State { provider: AlbumDirProvider( dirs: [p], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider(isAscending: false), ); _log.info("[_createAllAlbums] Creating dir album: $album"); diff --git a/app/lib/widget/new_collection_dialog/bloc.dart b/app/lib/widget/new_collection_dialog/bloc.dart index b16ff069..12639748 100644 --- a/app/lib/widget/new_collection_dialog/bloc.dart +++ b/app/lib/widget/new_collection_dialog/bloc.dart @@ -73,7 +73,7 @@ class _Bloc extends Bloc<_Event, _State> { album: Album( name: state.formValue.name, provider: AlbumStaticProvider(items: const []), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider(isAscending: false), ), ); @@ -84,7 +84,7 @@ class _Bloc extends Bloc<_Event, _State> { album: Album( name: state.formValue.name, provider: AlbumDirProvider(dirs: state.formValue.dirs), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider(isAscending: false), ), ); @@ -95,7 +95,7 @@ class _Bloc extends Bloc<_Event, _State> { album: Album( name: state.formValue.name, provider: AlbumTagProvider(tags: state.formValue.tags), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider(isAscending: false), ), ); diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index 5ef6cb85..3b554912 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -47,7 +47,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), )); }); @@ -83,7 +83,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), )); }); @@ -152,7 +152,7 @@ void main() { ), ], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), )); }); @@ -203,7 +203,7 @@ void main() { ), ], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), )); }); @@ -286,7 +286,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider( isAscending: true, ), @@ -326,7 +326,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumFilenameSortProvider( isAscending: true, ), @@ -369,7 +369,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: File(path: "remote.php/dav/files/admin/test1.jpg"), )); @@ -384,7 +384,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toRemoteJson(), { @@ -415,7 +415,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toRemoteJson(), { @@ -458,7 +458,7 @@ void main() { ), ], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toRemoteJson(), { @@ -516,7 +516,7 @@ void main() { ), ], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toRemoteJson(), { @@ -593,7 +593,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider( isAscending: true, ), @@ -628,7 +628,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumFilenameSortProvider( isAscending: true, ), @@ -667,7 +667,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toAppDbJson(), { @@ -698,7 +698,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toAppDbJson(), { @@ -741,7 +741,7 @@ void main() { ), ], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toAppDbJson(), { @@ -799,7 +799,7 @@ void main() { ), ], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), ); expect(album.toAppDbJson(), { @@ -879,7 +879,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumTimeSortProvider( isAscending: true, ), @@ -914,7 +914,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumFilenameSortProvider( isAscending: true, ), @@ -951,7 +951,7 @@ void main() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: File(path: "remote.php/dav/files/admin/test1.jpg"), ); @@ -1762,8 +1762,16 @@ void main() { group("AlbumUpgraderV8", () { test("non manual cover", _upgradeV8NonManualCover); - test("manual cover", _upgradeV8ManualCover); - test("manual cover (exif time)", _upgradeV8ManualExifTime); + + group("manual cover", () { + test("now", _upgradeV8ManualNow); + test("exif time", _upgradeV8ManualExifTime); + }); + + group("auto cover", () { + test("null", _upgradeV8AutoNull); + test("last modified", _upgradeV8AutoLastModified); + }); }); }); } @@ -1806,7 +1814,7 @@ void _fromJsonShares() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), shares: [util.buildAlbumShare(userId: "admin")], )); @@ -1819,7 +1827,7 @@ void _toRemoteJsonShares() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), shares: [util.buildAlbumShare(userId: "admin")], ); @@ -1858,7 +1866,7 @@ void _toAppDbJsonShares() { provider: AlbumStaticProvider( items: [], ), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), shares: [util.buildAlbumShare(userId: "admin")], ); @@ -1953,7 +1961,7 @@ void _upgradeV8NonManualCover() { }); } -void _upgradeV8ManualCover() { +void _upgradeV8ManualNow() { withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { final json = { "version": 8, @@ -2079,6 +2087,112 @@ void _upgradeV8ManualExifTime() { }); } +void _upgradeV8AutoNull() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8AutoLastModified() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "lastModified": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + // dart does not provide a way to mock timezone + "fdDateTime": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { const _NullAlbumUpgraderFactory(); diff --git a/app/test/use_case/remove_from_album_test.dart b/app/test/use_case/remove_from_album_test.dart index 12f5d1a2..d4950fe1 100644 --- a/app/test/use_case/remove_from_album_test.dart +++ b/app/test/use_case/remove_from_album_test.dart @@ -74,7 +74,7 @@ Future _removeLastFile() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider(items: []), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: albumFile, ), diff --git a/app/test/use_case/remove_test.dart b/app/test/use_case/remove_test.dart index cf10e94e..e82b08e1 100644 --- a/app/test/use_case/remove_test.dart +++ b/app/test/use_case/remove_test.dart @@ -119,7 +119,7 @@ Future _removeAlbumFile() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider(items: []), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: albumFile, ), @@ -216,7 +216,7 @@ Future _removeSharedAlbumFile() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider(items: []), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: albumFile, shares: [ @@ -286,7 +286,7 @@ Future _removeSharedAlbumSharedFile() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider(items: []), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: albumFile, shares: [ @@ -351,7 +351,7 @@ Future _removeSharedAlbumResyncedFile() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider(items: []), - coverProvider: AlbumAutoCoverProvider(), + coverProvider: const AlbumAutoCoverProvider(), sortProvider: const AlbumNullSortProvider(), albumFile: albumFile, shares: [ From 31de51f7557692db0c2baacd3f3f053b98b8c2f6 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 19 Apr 2023 23:51:30 +0800 Subject: [PATCH 10/67] Make FileDescriptor's json serialization method more explicit --- app/lib/entity/album/cover_provider.dart | 6 +++--- app/lib/entity/file.dart | 13 +++++++++++- app/lib/entity/file_descriptor.dart | 16 +++++++------- app/test/entity/album/data_source_test.dart | 7 ++++--- app/test/entity/album_test.dart | 23 ++++++++++++++++++--- 5 files changed, 48 insertions(+), 17 deletions(-) diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index 83c52a0b..a3f3e7ee 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -110,7 +110,7 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { @override JsonObj _toContentJson() { return { - if (coverFile != null) "coverFile": coverFile!.toJson(), + if (coverFile != null) "coverFile": coverFile!.toFdJson(), }; } @@ -147,7 +147,7 @@ class AlbumManualCoverProvider extends AlbumCoverProvider { @override JsonObj _toContentJson() { return { - "coverFile": coverFile.toJson(), + "coverFile": coverFile.toFdJson(), }; } @@ -183,7 +183,7 @@ class AlbumMemoryCoverProvider extends AlbumCoverProvider { @override _toContentJson() => { - "coverFile": coverFile.toJson(), + "coverFile": coverFile.toFdJson(), }; final FileDescriptor coverFile; diff --git a/app/lib/entity/file.dart b/app/lib/entity/file.dart index 58838f50..edf0333c 100644 --- a/app/lib/entity/file.dart +++ b/app/lib/entity/file.dart @@ -390,7 +390,6 @@ class File with EquatableMixin implements FileDescriptor { @override String toString() => _$toString(); - @override JsonObj toJson() { return { "path": path, @@ -419,6 +418,9 @@ class File with EquatableMixin implements FileDescriptor { }; } + @override + JsonObj toFdJson() => FileDescriptor.toJson(this); + File copyWith({ String? path, int? contentLength, @@ -537,6 +539,15 @@ extension FileExtension on File { ); bool isOwned(CiString userId) => ownerId == null || ownerId == userId; + + FileDescriptor toDescriptor() => FileDescriptor( + fdPath: path, + fdId: fileId!, + fdMime: contentType, + fdIsArchived: isArchived ?? false, + fdIsFavorite: isFavorite ?? false, + fdDateTime: bestDateTime, + ); } class FileServerIdentityComparator { diff --git a/app/lib/entity/file_descriptor.dart b/app/lib/entity/file_descriptor.dart index ec4e28ec..94dc5e6b 100644 --- a/app/lib/entity/file_descriptor.dart +++ b/app/lib/entity/file_descriptor.dart @@ -33,15 +33,17 @@ class FileDescriptor with EquatableMixin { fdDateTime: DateTime.parse(json["fdDateTime"]), ); - JsonObj toJson() => { - "fdPath": fdPath, - "fdId": fdId, - "fdMime": fdMime, - "fdIsArchived": fdIsArchived, - "fdIsFavorite": fdIsFavorite, - "fdDateTime": fdDateTime.toUtc().toIso8601String(), + static JsonObj toJson(FileDescriptor that) => { + "fdPath": that.fdPath, + "fdId": that.fdId, + "fdMime": that.fdMime, + "fdIsArchived": that.fdIsArchived, + "fdIsFavorite": that.fdIsFavorite, + "fdDateTime": that.fdDateTime.toUtc().toIso8601String(), }; + JsonObj toFdJson() => toJson(this); + @override get props => [ fdPath, diff --git a/app/test/entity/album/data_source_test.dart b/app/test/entity/album/data_source_test.dart index 446fa83e..4d60fe09 100644 --- a/app/test/entity/album/data_source_test.dart +++ b/app/test/entity/album/data_source_test.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/entity/album/data_source.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/exception.dart'; import 'package:nc_photos/or_null.dart'; @@ -180,7 +181,7 @@ Future _dbUpdateExisting() async { ), ], ), - coverProvider: AlbumManualCoverProvider(coverFile: files[1]), + coverProvider: AlbumManualCoverProvider(coverFile: files[1].toDescriptor()), sortProvider: const AlbumTimeSortProvider(isAscending: true), ); final src = AlbumSqliteDbDataSource(c); @@ -260,7 +261,7 @@ Future _dbUpdateShares() async { ), ], ), - coverProvider: AlbumManualCoverProvider(coverFile: files[1]), + coverProvider: AlbumManualCoverProvider(coverFile: files[1].toDescriptor()), sortProvider: const AlbumTimeSortProvider(isAscending: true), shares: OrNull([ AlbumShare( @@ -322,7 +323,7 @@ Future _dbUpdateDeleteShares() async { ), ], ), - coverProvider: AlbumManualCoverProvider(coverFile: files[1]), + coverProvider: AlbumManualCoverProvider(coverFile: files[1].toDescriptor()), sortProvider: const AlbumTimeSortProvider(isAscending: true), shares: OrNull(null), ); diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index 3b554912..ee71a03f 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -558,7 +558,12 @@ void main() { items: [], ), coverProvider: AlbumAutoCoverProvider( - coverFile: File(path: "remote.php/dav/files/admin/test1.jpg")), + coverFile: File( + path: "remote.php/dav/files/admin/test1.jpg", + fileId: 1, + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5), + ), + ), sortProvider: const AlbumNullSortProvider(), ); expect(album.toRemoteJson(), { @@ -575,7 +580,12 @@ void main() { "type": "auto", "content": { "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z", }, }, }, @@ -843,6 +853,8 @@ void main() { coverProvider: AlbumAutoCoverProvider( coverFile: File( path: "remote.php/dav/files/admin/test1.jpg", + fileId: 1, + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5), ), ), sortProvider: const AlbumNullSortProvider(), @@ -861,7 +873,12 @@ void main() { "type": "auto", "content": { "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z", }, }, }, From f99fc25c714aeae06592eb115d8f31abcc3b9663 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 20 Apr 2023 01:21:59 +0800 Subject: [PATCH 11/67] Update test cases --- app/test/entity/album_test.dart | 66 +++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index ee71a03f..278f9c2c 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -7,6 +7,7 @@ import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/album/upgrader.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:test/test.dart'; @@ -224,7 +225,12 @@ void main() { "type": "auto", "content": { "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", }, }, }, @@ -234,23 +240,29 @@ void main() { }, }; expect( - Album.fromJson( - json, - upgraderFactory: const _NullAlbumUpgraderFactory(), + Album.fromJson( + json, + upgraderFactory: const _NullAlbumUpgraderFactory(), + ), + Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), + name: "", + provider: AlbumStaticProvider( + items: [], ), - Album( - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), - name: "", - provider: AlbumStaticProvider( - items: [], + coverProvider: AlbumAutoCoverProvider( + coverFile: FileDescriptor( + fdPath: "remote.php/dav/files/admin/test1.jpg", + fdId: 1, + fdMime: null, + fdIsFavorite: false, + fdIsArchived: false, + fdDateTime: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), ), - coverProvider: AlbumAutoCoverProvider( - coverFile: File( - path: "remote.php/dav/files/admin/test1.jpg", - ), - ), - sortProvider: const AlbumNullSortProvider(), - )); + ), + sortProvider: const AlbumNullSortProvider(), + ), + ); }); test("AlbumTimeSortProvider", () { @@ -558,10 +570,13 @@ void main() { items: [], ), coverProvider: AlbumAutoCoverProvider( - coverFile: File( - path: "remote.php/dav/files/admin/test1.jpg", - fileId: 1, - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5), + coverFile: FileDescriptor( + fdPath: "remote.php/dav/files/admin/test1.jpg", + fdId: 1, + fdMime: null, + fdIsFavorite: false, + fdIsArchived: false, + fdDateTime: DateTime.utc(2020, 1, 2, 3, 4, 5), ), ), sortProvider: const AlbumNullSortProvider(), @@ -851,10 +866,13 @@ void main() { items: [], ), coverProvider: AlbumAutoCoverProvider( - coverFile: File( - path: "remote.php/dav/files/admin/test1.jpg", - fileId: 1, - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5), + coverFile: FileDescriptor( + fdPath: "remote.php/dav/files/admin/test1.jpg", + fdId: 1, + fdMime: null, + fdIsFavorite: false, + fdIsArchived: false, + fdDateTime: DateTime.utc(2020, 1, 2, 3, 4, 5), ), ), sortProvider: const AlbumNullSortProvider(), From 0eb13273a1958fe932cecf6d6bd8b3aaf41072d5 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 20 Apr 2023 01:22:30 +0800 Subject: [PATCH 12/67] Add Album upgrader when reading from DB --- app/lib/entity/album.dart | 16 +- app/lib/entity/album/data_source2.dart | 7 +- app/lib/entity/album/upgrader.dart | 116 ++++++--- app/test/entity/album_test.dart | 322 ++++++++++++++++++++++--- 4 files changed, 384 insertions(+), 77 deletions(-) diff --git a/app/lib/entity/album.dart b/app/lib/entity/album.dart index 629c03b3..b77097d9 100644 --- a/app/lib/entity/album.dart +++ b/app/lib/entity/album.dart @@ -46,56 +46,56 @@ class Album with EquatableMixin { final jsonVersion = json["version"]; JsonObj? result = json; if (jsonVersion < 2) { - result = upgraderFactory?.buildV1()?.call(result); + result = upgraderFactory?.buildV1()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 3) { - result = upgraderFactory?.buildV2()?.call(result); + result = upgraderFactory?.buildV2()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 4) { - result = upgraderFactory?.buildV3()?.call(result); + result = upgraderFactory?.buildV3()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 5) { - result = upgraderFactory?.buildV4()?.call(result); + result = upgraderFactory?.buildV4()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 6) { - result = upgraderFactory?.buildV5()?.call(result); + result = upgraderFactory?.buildV5()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 7) { - result = upgraderFactory?.buildV6()?.call(result); + result = upgraderFactory?.buildV6()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 8) { - result = upgraderFactory?.buildV7()?.call(result); + result = upgraderFactory?.buildV7()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 9) { - result = upgraderFactory?.buildV8()?.call(result); + result = upgraderFactory?.buildV8()?.doJson(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; diff --git a/app/lib/entity/album/data_source2.dart b/app/lib/entity/album/data_source2.dart index 91cbef37..ab0ce37d 100644 --- a/app/lib/entity/album/data_source2.dart +++ b/app/lib/entity/album/data_source2.dart @@ -175,8 +175,13 @@ class AlbumSqliteDbDataSource2 implements AlbumDataSource2 { try { final queriedFile = sql.SqliteFileConverter.fromSql( account.userId.toString(), item["file"]); + var dbAlbum = item["album"] as sql.Album; + if (dbAlbum.version < 9) { + dbAlbum = AlbumUpgraderV8(logFilePath: queriedFile.path) + .doDb(dbAlbum)!; + } return sql.SqliteAlbumConverter.fromSql( - item["album"], queriedFile, item["shares"] ?? []); + dbAlbum, queriedFile, item["shares"] ?? []); } catch (e, stackTrace) { _log.severe("[getAlbums] Failed while converting DB entry", e, stackTrace); diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index d1cb98ef..6478a233 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/object_extension.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; @@ -13,7 +16,8 @@ import 'package:tuple/tuple.dart'; part 'upgrader.g.dart'; abstract class AlbumUpgrader { - JsonObj? call(JsonObj json); + JsonObj? doJson(JsonObj json); + sql.Album? doDb(sql.Album dbObj); } /// Upgrade v1 Album to v2 @@ -24,14 +28,17 @@ class AlbumUpgraderV1 implements AlbumUpgrader { }); @override - call(JsonObj json) { + doJson(JsonObj json) { // v1 album items are corrupted in one of the updates, drop it - _log.fine("[call] Upgrade v1 Album for file: $logFilePath"); + _log.fine("[doJson] Upgrade v1 Album for file: $logFilePath"); final result = JsonObj.from(json); result["items"] = []; return result; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + /// File path for logging only final String? logFilePath; } @@ -44,9 +51,9 @@ class AlbumUpgraderV2 implements AlbumUpgrader { }); @override - call(JsonObj json) { + doJson(JsonObj json) { // move v2 items to v3 provider - _log.fine("[call] Upgrade v2 Album for file: $logFilePath"); + _log.fine("[doJson] Upgrade v2 Album for file: $logFilePath"); final result = JsonObj.from(json); result["provider"] = { "type": "static", @@ -64,6 +71,9 @@ class AlbumUpgraderV2 implements AlbumUpgrader { return result; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + /// File path for logging only final String? logFilePath; } @@ -76,9 +86,9 @@ class AlbumUpgraderV3 implements AlbumUpgrader { }); @override - call(JsonObj json) { + doJson(JsonObj json) { // move v3 items to v4 provider - _log.fine("[call] Upgrade v3 Album for file: $logFilePath"); + _log.fine("[doJson] Upgrade v3 Album for file: $logFilePath"); final result = JsonObj.from(json); // add the descending time sort provider result["sortProvider"] = { @@ -90,6 +100,9 @@ class AlbumUpgraderV3 implements AlbumUpgrader { return result; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + /// File path for logging only final String? logFilePath; } @@ -102,8 +115,8 @@ class AlbumUpgraderV4 implements AlbumUpgrader { }); @override - call(JsonObj json) { - _log.fine("[call] Upgrade v4 Album for file: $logFilePath"); + doJson(JsonObj json) { + _log.fine("[doJson] Upgrade v4 Album for file: $logFilePath"); final result = JsonObj.from(json); try { if (result["provider"]["type"] != "static") { @@ -153,11 +166,14 @@ class AlbumUpgraderV4 implements AlbumUpgrader { } catch (e, stackTrace) { // this upgrade is not a must, if it failed then just leave it and it'll // be upgraded the next time the album is saved - _log.shout("[call] Failed while upgrade", e, stackTrace); + _log.shout("[doJson] Failed while upgrade", e, stackTrace); } return result; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + /// File path for logging only final String? logFilePath; } @@ -172,8 +188,8 @@ class AlbumUpgraderV5 implements AlbumUpgrader { }); @override - call(JsonObj json) { - _log.fine("[call] Upgrade v5 Album for file: $logFilePath"); + doJson(JsonObj json) { + _log.fine("[doJson] Upgrade v5 Album for file: $logFilePath"); final result = JsonObj.from(json); try { if (result["provider"]["type"] != "static") { @@ -194,11 +210,14 @@ class AlbumUpgraderV5 implements AlbumUpgrader { } catch (e, stackTrace) { // this upgrade is not a must, if it failed then just leave it and it'll // be upgraded the next time the album is saved - _log.shout("[call] Failed while upgrade", e, stackTrace); + _log.shout("[doJson] Failed while upgrade", e, stackTrace); } return result; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + final Account account; final File? albumFile; @@ -214,11 +233,14 @@ class AlbumUpgraderV6 implements AlbumUpgrader { }); @override - call(JsonObj json) { - _log.fine("[call] Upgrade v6 Album for file: $logFilePath"); + doJson(JsonObj json) { + _log.fine("[doJson] Upgrade v6 Album for file: $logFilePath"); return json; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + /// File path for logging only final String? logFilePath; } @@ -231,11 +253,14 @@ class AlbumUpgraderV7 implements AlbumUpgrader { }); @override - call(JsonObj json) { - _log.fine("[call] Upgrade v7 Album for file: $logFilePath"); + doJson(JsonObj json) { + _log.fine("[doJson] Upgrade v7 Album for file: $logFilePath"); return json; } + @override + sql.Album? doDb(sql.Album dbObj) => null; + /// File path for logging only final String? logFilePath; } @@ -248,35 +273,50 @@ class AlbumUpgraderV8 implements AlbumUpgrader { }); @override - JsonObj? call(JsonObj json) { - _log.fine("[call] Upgrade v8 Album for file: $logFilePath"); + JsonObj? doJson(JsonObj json) { + _log.fine("[doJson] Upgrade v8 Album for file: $logFilePath"); final result = JsonObj.from(json); - try { - if (result["coverProvider"]["type"] == "manual") { - final content = (result["coverProvider"]["content"]["coverFile"] as Map) - .cast(); + if (result["coverProvider"]["type"] == "manual") { + final content = (result["coverProvider"]["content"]["coverFile"] as Map) + .cast(); + final fd = _fileJsonToFileDescriptorJson(content); + result["coverProvider"]["content"]["coverFile"] = fd; + } else if (result["coverProvider"]["type"] == "auto") { + final content = (result["coverProvider"]["content"]["coverFile"] as Map?) + ?.cast(); + if (content != null) { final fd = _fileJsonToFileDescriptorJson(content); result["coverProvider"]["content"]["coverFile"] = fd; - } else if (result["coverProvider"]["type"] == "auto") { - final content = - (result["coverProvider"]["content"]["coverFile"] as Map?) - ?.cast(); - if (content != null) { - final fd = _fileJsonToFileDescriptorJson(content); - result["coverProvider"]["content"]["coverFile"] = fd; - } - return result; - } else { - return result; } - } catch (e, stackTrace) { - // this upgrade is not a must, if it failed then just leave it and it'll - // be upgraded the next time the album is saved - _log.shout("[call] Failed while upgrade", e, stackTrace); } return result; } + @override + sql.Album? doDb(sql.Album dbObj) { + _log.fine("[doDb] Upgrade v8 Album for file: $logFilePath"); + if (dbObj.coverProviderType == "manual") { + final content = (jsonDecode(dbObj.coverProviderContent) as Map) + .cast(); + final converted = _fileJsonToFileDescriptorJson( + (content["coverFile"] as Map).cast()); + return dbObj.copyWith( + coverProviderContent: jsonEncode({"coverFile": converted}), + ); + } else if (dbObj.coverProviderType == "auto") { + final content = (jsonDecode(dbObj.coverProviderContent) as Map) + .cast(); + if (content["coverFile"] != null) { + final converted = _fileJsonToFileDescriptorJson( + (content["coverFile"] as Map).cast()); + return dbObj.copyWith( + coverProviderContent: jsonEncode({"coverFile": converted}), + ); + } + } + return dbObj; + } + static JsonObj _fileJsonToFileDescriptorJson(JsonObj json) { return { "fdPath": json["path"], diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index 278f9c2c..50996184 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:clock/clock.dart'; import 'package:intl/intl.dart'; import 'package:nc_photos/entity/album.dart'; @@ -8,6 +10,7 @@ import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/album/upgrader.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:test/test.dart'; @@ -1033,7 +1036,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV1()(json), { + expect(AlbumUpgraderV1().doJson(json), { "version": 1, "lastUpdated": "2020-01-02T03:04:05.678901Z", "items": [], @@ -1061,7 +1064,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV2()(json), { + expect(AlbumUpgraderV2().doJson(json), { "version": 2, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1116,7 +1119,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV3()(json), { + expect(AlbumUpgraderV3().doJson(json), { "version": 3, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1182,7 +1185,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV4()(json), { + expect(AlbumUpgraderV4().doJson(json), { "version": 4, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1252,7 +1255,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV4()(json), { + expect(AlbumUpgraderV4().doJson(json), { "version": 4, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1320,7 +1323,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV4()(json), { + expect(AlbumUpgraderV4().doJson(json), { "version": 4, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1403,7 +1406,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV4()(json), { + expect(AlbumUpgraderV4().doJson(json), { "version": 4, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1476,7 +1479,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV4()(json), { + expect(AlbumUpgraderV4().doJson(json), { "version": 4, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1556,7 +1559,7 @@ void main() { "ownerId": "admin", }, }; - expect(AlbumUpgraderV5(account)(json), { + expect(AlbumUpgraderV5(account).doJson(json), { "version": 5, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1626,7 +1629,7 @@ void main() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(AlbumUpgraderV5(account)(json), { + expect(AlbumUpgraderV5(account).doJson(json), { "version": 5, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1692,7 +1695,7 @@ void main() { }, }, }; - expect(AlbumUpgraderV5(account)(json), { + expect(AlbumUpgraderV5(account).doJson(json), { "version": 5, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1760,7 +1763,7 @@ void main() { ownerId: "admin".toCi(), ); expect( - AlbumUpgraderV5(account, albumFile: albumFile)(json), + AlbumUpgraderV5(account, albumFile: albumFile).doJson(json), { "version": 5, "lastUpdated": "2020-01-02T03:04:05.678901Z", @@ -1796,16 +1799,32 @@ void main() { }); group("AlbumUpgraderV8", () { - test("non manual cover", _upgradeV8NonManualCover); + group("doJson", () { + test("non manual cover", _upgradeV8JsonNonManualCover); - group("manual cover", () { - test("now", _upgradeV8ManualNow); - test("exif time", _upgradeV8ManualExifTime); + group("manual cover", () { + test("now", _upgradeV8JsonManualNow); + test("exif time", _upgradeV8JsonManualExifTime); + }); + + group("auto cover", () { + test("null", _upgradeV8JsonAutoNull); + test("last modified", _upgradeV8JsonAutoLastModified); + }); }); - group("auto cover", () { - test("null", _upgradeV8AutoNull); - test("last modified", _upgradeV8AutoLastModified); + group("doDb", () { + test("non manual cover", _upgradeV8DbNonManualCover); + + group("manual cover", () { + test("now", _upgradeV8DbManualNow); + test("exif time", _upgradeV8DbManualExifTime); + }); + + group("auto cover", () { + test("null", _upgradeV8DbAutoNull); + test("last modified", _upgradeV8DbAutoLastModified); + }); }); }); }); @@ -1933,7 +1952,7 @@ void _toAppDbJsonShares() { }); } -void _upgradeV8NonManualCover() { +void _upgradeV8JsonNonManualCover() { final json = { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", @@ -1964,7 +1983,7 @@ void _upgradeV8NonManualCover() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(const AlbumUpgraderV8()(json), { + expect(const AlbumUpgraderV8().doJson(json), { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -1996,7 +2015,7 @@ void _upgradeV8NonManualCover() { }); } -void _upgradeV8ManualNow() { +void _upgradeV8JsonManualNow() { withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { final json = { "version": 8, @@ -2024,7 +2043,7 @@ void _upgradeV8ManualNow() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(const AlbumUpgraderV8()(json), { + expect(const AlbumUpgraderV8().doJson(json), { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -2057,7 +2076,7 @@ void _upgradeV8ManualNow() { }); } -void _upgradeV8ManualExifTime() { +void _upgradeV8JsonManualExifTime() { final json = { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", @@ -2089,7 +2108,7 @@ void _upgradeV8ManualExifTime() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(const AlbumUpgraderV8()(json), { + expect(const AlbumUpgraderV8().doJson(json), { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -2122,7 +2141,7 @@ void _upgradeV8ManualExifTime() { }); } -void _upgradeV8AutoNull() { +void _upgradeV8JsonAutoNull() { final json = { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", @@ -2144,7 +2163,7 @@ void _upgradeV8AutoNull() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(const AlbumUpgraderV8()(json), { + expect(const AlbumUpgraderV8().doJson(json), { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -2167,7 +2186,7 @@ void _upgradeV8AutoNull() { }); } -void _upgradeV8AutoLastModified() { +void _upgradeV8JsonAutoLastModified() { final json = { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", @@ -2195,7 +2214,7 @@ void _upgradeV8AutoLastModified() { "path": "remote.php/dav/files/admin/test1.json", }, }; - expect(const AlbumUpgraderV8()(json), { + expect(const AlbumUpgraderV8().doJson(json), { "version": 8, "lastUpdated": "2020-01-02T03:04:05.678901Z", "provider": { @@ -2213,7 +2232,6 @@ void _upgradeV8AutoLastModified() { "fdMime": null, "fdIsArchived": false, "fdIsFavorite": false, - // dart does not provide a way to mock timezone "fdDateTime": "2020-01-02T03:04:05.000Z", }, }, @@ -2228,6 +2246,250 @@ void _upgradeV8AutoLastModified() { }); } +void _upgradeV8DbNonManualCover() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "memory", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "memory", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbManualNow() { + withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1 + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); + }); +} + +void _upgradeV8DbManualExifTime() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "metadata": { + "exif": { + "DateTimeOriginal": "2020:01:02 03:04:05" + } + } + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + // dart does not provide a way to mock timezone + final dateTime = DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "$dateTime" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbAutoNull() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: "{}", + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: "{}", + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbAutoLastModified() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "lastModified": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +String _stripJsonString(String str) { + return jsonEncode(jsonDecode(str)); +} + class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { const _NullAlbumUpgraderFactory(); From 203eaf290a0e5bc62d08f4855c129ec647af686f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 22 Apr 2023 11:43:13 +0800 Subject: [PATCH 13/67] Regression: dynamic album cover not shown even after opening it --- .../collection_items_controller.dart | 6 +++++ app/lib/entity/collection/adapter.dart | 4 ++++ app/lib/entity/collection/adapter/album.dart | 18 +++++++++++++++ .../entity/collection/adapter/nc_album.dart | 4 ++++ .../collection/adapter/read_only_adapter.dart | 4 ++++ .../update_collection_post_load.dart | 22 +++++++++++++++++++ 6 files changed, 58 insertions(+) create mode 100644 app/lib/use_case/collection/update_collection_post_load.dart diff --git a/app/lib/controller/collection_items_controller.dart b/app/lib/controller/collection_items_controller.dart index 8fa0d569..5b12ccad 100644 --- a/app/lib/controller/collection_items_controller.dart +++ b/app/lib/controller/collection_items_controller.dart @@ -21,6 +21,7 @@ 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/collection/update_collection_post_load.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:rxdart/rxdart.dart'; @@ -289,6 +290,11 @@ class CollectionItemsController { items: items, hasNext: false, )); + final newCollection = + await UpdateCollectionPostLoad(_c)(account, collection, items); + if (newCollection != null) { + onCollectionUpdated(newCollection); + } } } catch (e, stackTrace) { _dataStreamController diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index 5adc9596..aeb7f3ca 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -79,6 +79,10 @@ abstract class CollectionAdapter { /// Return if the cover of this collection has been manually set bool isManualCover(); + + /// Called when the collection items belonging to this collection is first + /// loaded + Future updatePostLoad(List items); } abstract class CollectionItemAdapter { diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 94d5c16e..8ec4aa84 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -24,6 +24,7 @@ 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:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/type.dart'; import 'package:tuple/tuple.dart'; @@ -222,6 +223,23 @@ class CollectionAlbumAdapter implements CollectionAdapter { bool isManualCover() => _provider.album.coverProvider is AlbumManualCoverProvider; + @override + Future updatePostLoad(List items) async { + final album = await UpdateAlbumWithActualItems(_c.albumRepo)( + account, + _provider.album, + items + .whereType() + .map((e) => e.albumItem) + .toList(), + ); + if (!identical(album, _provider.album)) { + return CollectionBuilder.byAlbum(account, album); + } else { + return null; + } + } + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index 33f92524..302d85a5 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -145,6 +145,10 @@ class CollectionNcAlbumAdapter implements CollectionAdapter { @override bool isManualCover() => false; + @override + Future updatePostLoad(List items) => + Future.value(null); + Future _syncRemote() async { final remote = await ListNcAlbum(_c)(account).last; return remote.firstWhere((e) => e.compareIdentity(_provider.album)); diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/read_only_adapter.dart index 80b1f96d..a413f9cf 100644 --- a/app/lib/entity/collection/adapter/read_only_adapter.dart +++ b/app/lib/entity/collection/adapter/read_only_adapter.dart @@ -42,4 +42,8 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { @override bool isManualCover() => false; + + @override + Future updatePostLoad(List items) => + Future.value(null); } diff --git a/app/lib/use_case/collection/update_collection_post_load.dart b/app/lib/use_case/collection/update_collection_post_load.dart new file mode 100644 index 00000000..5c3b403e --- /dev/null +++ b/app/lib/use_case/collection/update_collection_post_load.dart @@ -0,0 +1,22 @@ +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'; + +class UpdateCollectionPostLoad { + const UpdateCollectionPostLoad(this._c); + + /// Update the collection after its items are loaded if necessary + /// + /// Return a new collection if its updated, otherwise null + Future call( + Account account, + Collection collection, + List items, + ) { + return CollectionAdapter.of(_c, account, collection).updatePostLoad(items); + } + + final DiContainer _c; +} From 02107c7150682f910104612b62371a04d2281f2c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 22 Apr 2023 18:51:28 +0800 Subject: [PATCH 14/67] Fix album upgrader assuming wrong data type --- app/lib/entity/album/upgrader.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index 6478a233..89a204ac 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -323,7 +323,8 @@ class AlbumUpgraderV8 implements AlbumUpgrader { "fdId": json["fileId"], "fdMime": json["contentType"], "fdIsArchived": json["isArchived"] ?? false, - "fdIsFavorite": json["isFavorite"] ?? false, + // File.isFavorite is serialized as int + "fdIsFavorite": json["isFavorite"] == 1, "fdDateTime": json["overrideDateTime"] ?? (json["metadata"]?["exif"]?["DateTimeOriginal"] as String?)?.run( (d) => Exif.dateTimeFormat.parse(d).toUtc().toIso8601String()) ?? From c62af9941bee943913672daef1531112562f7e4f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 22 Apr 2023 22:32:34 +0800 Subject: [PATCH 15/67] Regression: album cover is gone after unsetting one for a dynamic album --- .../collection_items_controller.dart | 3 +++ .../controller/collections_controller.dart | 5 ++++ app/lib/entity/album/cover_provider.dart | 27 +++++++++---------- app/lib/entity/collection/adapter.dart | 1 + app/lib/entity/collection/adapter/album.dart | 5 ++++ .../entity/collection/adapter/nc_album.dart | 1 + .../collection/adapter/read_only_adapter.dart | 1 + app/lib/use_case/album/edit_album.dart | 12 ++++++++- .../use_case/collection/edit_collection.dart | 7 +++++ 9 files changed, 47 insertions(+), 15 deletions(-) diff --git a/app/lib/controller/collection_items_controller.dart b/app/lib/controller/collection_items_controller.dart index 5b12ccad..08f3848e 100644 --- a/app/lib/controller/collection_items_controller.dart +++ b/app/lib/controller/collection_items_controller.dart @@ -76,6 +76,9 @@ class CollectionItemsController { return _dataStreamController.stream; } + /// Peek the stream and return the current value + CollectionItemStreamData peekStream() => _dataStreamController.stream.value; + /// Add list of [files] to [collection] Future addFiles(List files) async { final isInited = _isDataStreamInited; diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 246e6aad..7ec448fa 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:copy_with/copy_with.dart'; import 'package:logging/logging.dart'; import 'package:mutex/mutex.dart'; @@ -163,6 +164,9 @@ class CollectionsController { }) async { try { final c = await _mutex.protect(() async { + final found = _dataStreamController.value.data.firstWhereOrNull( + (ev) => ev.collection.compareIdentity(collection)); + final item = found?.controller.peekStream(); return await EditCollection(_c)( account, collection, @@ -170,6 +174,7 @@ class CollectionsController { items: items, itemSort: itemSort, cover: cover, + knownItems: (item?.items.isEmpty ?? true) ? null : item!.items, ); }); _updateCollection(c, items); diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index a3f3e7ee..fd660278 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -77,26 +77,25 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { ); } + static FileDescriptor? getCoverByItems(List items) { + return items + .whereType() + .map((e) => e.file) + .where((element) => + file_util.isSupportedFormat(element) && + (element.hasPreview ?? false)) + .sorted(compareFileDateTimeDescending) + .firstOrNull; + } + @override String toString() => _$toString(); @override FileDescriptor? getCover(Album album) { if (coverFile == null) { - try { - // use the latest file as cover - return AlbumStaticProvider.of(album) - .items - .whereType() - .map((e) => e.file) - .where((element) => - file_util.isSupportedFormat(element) && - (element.hasPreview ?? false)) - .sorted(compareFileDateTimeDescending) - .first; - } catch (_) { - return null; - } + // use the latest file as cover + return getCoverByItems(AlbumStaticProvider.of(album).items); } else { return coverFile; } diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index aeb7f3ca..87c89d70 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -57,6 +57,7 @@ abstract class CollectionAdapter { List? items, CollectionItemSort? itemSort, OrNull? cover, + List? knownItems, }); /// Remove [items] from this collection and return the removed count diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 8ec4aa84..9e711f72 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -79,6 +79,7 @@ class CollectionAlbumAdapter implements CollectionAdapter { List? items, CollectionItemSort? itemSort, OrNull? cover, + List? knownItems, }) async { assert(name != null || items != null || itemSort != null || cover != null); final newItems = items?.run((items) => items @@ -106,6 +107,10 @@ class CollectionAlbumAdapter implements CollectionAdapter { items: newItems, itemSort: itemSort, cover: cover, + knownItems: knownItems + ?.whereType() + .map((e) => e.albumItem) + .toList(), ); return collection.copyWith( name: name, diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index 302d85a5..28072cf7 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -77,6 +77,7 @@ class CollectionNcAlbumAdapter implements CollectionAdapter { List? items, CollectionItemSort? itemSort, OrNull? cover, + List? knownItems, }) async { assert(name != null); if (items != null || itemSort != null || cover != null) { diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/read_only_adapter.dart index a413f9cf..e5766f18 100644 --- a/app/lib/entity/collection/adapter/read_only_adapter.dart +++ b/app/lib/entity/collection/adapter/read_only_adapter.dart @@ -24,6 +24,7 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { List? items, CollectionItemSort? itemSort, OrNull? cover, + List? knownItems, }) { throw UnsupportedError("Operation not supported"); } diff --git a/app/lib/use_case/album/edit_album.dart b/app/lib/use_case/album/edit_album.dart index 021a6282..efd9a642 100644 --- a/app/lib/use_case/album/edit_album.dart +++ b/app/lib/use_case/album/edit_album.dart @@ -26,6 +26,7 @@ class EditAlbum { List? items, CollectionItemSort? itemSort, OrNull? cover, + List? knownItems, }) async { _log.info( "[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort, cover: $cover"); @@ -49,8 +50,9 @@ class EditAlbum { } if (cover != null) { if (cover.obj == null) { + final coverFile = _getCoverFile(knownItems); newAlbum = newAlbum.copyWith( - coverProvider: const AlbumAutoCoverProvider(), + coverProvider: AlbumAutoCoverProvider(coverFile: coverFile), ); } else { newAlbum = newAlbum.copyWith( @@ -65,5 +67,13 @@ class EditAlbum { return newAlbum; } + FileDescriptor? _getCoverFile(List? items) { + if (items?.isEmpty ?? true) { + return null; + } else { + return AlbumAutoCoverProvider.getCoverByItems(items!); + } + } + final DiContainer _c; } diff --git a/app/lib/use_case/collection/edit_collection.dart b/app/lib/use_case/collection/edit_collection.dart index 15d11a07..07a5291f 100644 --- a/app/lib/use_case/collection/edit_collection.dart +++ b/app/lib/use_case/collection/edit_collection.dart @@ -19,6 +19,11 @@ class EditCollection { /// - Sort [items] (set [items] and/or [itemSort]) /// - Set album [cover] /// + /// Optionally you may provide a list of known collection items. If + /// [knownItems] is not null, it may be used as a hint for the implementors + /// when updating the underlying collection (e.g., setting the latest item as + /// cover image) + /// /// \* To add files to a collection, use [AddFileToCollection] instead Future call( Account account, @@ -27,12 +32,14 @@ class EditCollection { List? items, CollectionItemSort? itemSort, OrNull? cover, + List? knownItems, }) => CollectionAdapter.of(_c, account, collection).edit( name: name, items: items, itemSort: itemSort, cover: cover, + knownItems: knownItems, ); final DiContainer _c; From 2291e3b158348473a700e83ac4e78c0ab6f09147 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 23 Apr 2023 01:37:39 +0800 Subject: [PATCH 16/67] Fix outdated albums not getting updated to cache correctly --- app/lib/entity/album/repo2.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/lib/entity/album/repo2.dart b/app/lib/entity/album/repo2.dart index 2069489b..27fefebd 100644 --- a/app/lib/entity/album/repo2.dart +++ b/app/lib/entity/album/repo2.dart @@ -92,7 +92,9 @@ class CachedAlbumRepo2 implements AlbumRepo2 { // query remote final outdated = [ ...failed, - ...cachedGroup[false]?.map((e) => e.albumFile!) ?? const [], + ...cachedGroup[false]?.map((e) => + albumFiles.firstWhere(e.albumFile!.compareServerIdentity)) ?? + const [], ]; final remote = await remoteDataSrc.getAlbums(account, outdated, onError: onError); From e7212e064321ca4f0e1ab5d811bee50fca349f57 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 23 Apr 2023 22:10:30 +0800 Subject: [PATCH 17/67] Remove obsolete code --- app/lib/entity/collection/adapter/album.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 9e711f72..36486eb5 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -251,5 +251,3 @@ class CollectionAlbumAdapter implements CollectionAdapter { final CollectionAlbumProvider _provider; } - -// class CollectionAlbumItemAdapter implements CollectionItemAdapter {} From 3f38efccf3c11032c5a4e40aa45286c9275d5c9a Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 26 Apr 2023 01:02:39 +0800 Subject: [PATCH 18/67] Export collection with different provider --- app/lib/entity/collection/exporter.dart | 108 +++++++++ app/lib/entity/collection/exporter.g.dart | 14 ++ app/lib/widget/collection_browser.dart | 1 + app/lib/widget/collection_browser.g.dart | 7 + .../widget/collection_browser/app_bar.dart | 26 +- .../collection_browser/state_event.dart | 8 + app/lib/widget/export_collection_dialog.dart | 225 ++++++++++++++++++ .../widget/export_collection_dialog.g.dart | 113 +++++++++ .../widget/export_collection_dialog/bloc.dart | 63 +++++ .../export_collection_dialog/state_event.dart | 71 ++++++ 10 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 app/lib/entity/collection/exporter.dart create mode 100644 app/lib/entity/collection/exporter.g.dart create mode 100644 app/lib/widget/export_collection_dialog.dart create mode 100644 app/lib/widget/export_collection_dialog.g.dart create mode 100644 app/lib/widget/export_collection_dialog/bloc.dart create mode 100644 app/lib/widget/export_collection_dialog/state_event.dart diff --git a/app/lib/entity/collection/exporter.dart b/app/lib/entity/collection/exporter.dart new file mode 100644 index 00000000..9db4a003 --- /dev/null +++ b/app/lib/entity/collection/exporter.dart @@ -0,0 +1,108 @@ +import 'package:clock/clock.dart'; +import 'package:collection/collection.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/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/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_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/use_case/find_file.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'exporter.g.dart'; + +@npLog +class CollectionExporter { + const CollectionExporter(this.account, this.collectionsController, + this.collection, this.items, this.exportName); + + /// Export as a new collection backed by our client side album + Future asAlbum() async { + final files = await FindFile(KiwiContainer().resolve())( + account, + items.whereType().map((e) => e.file.fdId).toList(), + onFileNotFound: (fileId) { + _log.severe("[asAlbum] File not found: $fileId"); + }, + ); + final newAlbum = Album( + name: exportName, + provider: AlbumStaticProvider( + items: items + .map((e) { + if (e is CollectionFileItem) { + final f = files + .firstWhereOrNull((f) => f.compareServerIdentity(e.file)); + if (f == null) { + return null; + } else { + return AlbumFileItem( + addedBy: account.userId, + addedAt: clock.now().toUtc(), + file: f, + ); + } + } else if (e is CollectionLabelItem) { + return AlbumLabelItem( + addedBy: account.userId, + addedAt: clock.now().toUtc(), + text: e.text, + ); + } else { + return null; + } + }) + .whereNotNull() + .toList(), + latestItemTime: collection.lastModified, + ), + coverProvider: const AlbumAutoCoverProvider(), + sortProvider: const AlbumTimeSortProvider(isAscending: false), + ); + var newCollection = Collection( + name: exportName, + contentProvider: CollectionAlbumProvider( + account: account, + album: newAlbum, + ), + ); + return await collectionsController.createNew(newCollection); + } + + /// Export as a new collection backed by Nextcloud album + Future asNcAlbum() async { + var newCollection = Collection( + name: exportName, + contentProvider: CollectionNcAlbumProvider( + account: account, + album: NcAlbum.createNew(account: account, name: exportName), + ), + ); + newCollection = await collectionsController.createNew(newCollection); + // only files are supported in NcAlbum + final newFiles = + items.whereType().map((e) => e.file).toList(); + final data = collectionsController + .peekStream() + .data + .firstWhere((e) => e.collection.compareIdentity(newCollection)); + await data.controller.addFiles(newFiles); + return newCollection; + } + + final Account account; + final CollectionsController collectionsController; + final Collection collection; + final List items; + final String exportName; +} diff --git a/app/lib/entity/collection/exporter.g.dart b/app/lib/entity/collection/exporter.g.dart new file mode 100644 index 00000000..1ed9e5b7 --- /dev/null +++ b/app/lib/entity/collection/exporter.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'exporter.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionExporterNpLog on CollectionExporter { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.collection.exporter.CollectionExporter"); +} diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 8698aa17..7cc7a4ba 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -43,6 +43,7 @@ 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/export_collection_dialog.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'; diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index 46e3f96d..ad8c948b 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -160,6 +160,13 @@ extension _$_DownloadToString on _Download { } } +extension _$_ExportToString on _Export { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Export {}"; + } +} + extension _$_BeginEditToString on _BeginEdit { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index b69d46d2..fc148692 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -41,11 +41,16 @@ class _AppBar extends StatelessWidget { value: _MenuOption.unsetCover, child: Text(L10n.global().unsetAlbumCoverTooltip), ), - if (state.items.isNotEmpty) + if (state.items.isNotEmpty) ...[ PopupMenuItem( value: _MenuOption.download, child: Text(L10n.global().downloadTooltip), ), + const PopupMenuItem( + value: _MenuOption.export, + child: Text("Export"), + ), + ], ], onSelected: (option) { _onMenuSelected(context, option); @@ -82,6 +87,24 @@ class _AppBar extends StatelessWidget { case _MenuOption.download: context.read<_Bloc>().add(const _Download()); break; + case _MenuOption.export: + _onExportSelected(context); + break; + } + } + + Future _onExportSelected(BuildContext context) async { + final bloc = context.read<_Bloc>(); + final result = await showDialog( + context: context, + builder: (_) => ExportCollectionDialog( + account: bloc.account, + collection: bloc.state.collection, + items: bloc.state.items, + ), + ); + if (result != null) { + Navigator.of(context).pop(); } } } @@ -374,6 +397,7 @@ enum _MenuOption { edit, unsetCover, download, + export, } enum _SelectionMenuOption { diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index c92b9ada..01b5fa9c 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -112,6 +112,14 @@ class _Download implements _Event { String toString() => _$toString(); } +@toString +class _Export implements _Event { + const _Export(); + + @override + String toString() => _$toString(); +} + @toString class _BeginEdit implements _Event { const _BeginEdit(); diff --git a/app/lib/widget/export_collection_dialog.dart b/app/lib/widget/export_collection_dialog.dart new file mode 100644 index 00000000..c69f63d7 --- /dev/null +++ b/app/lib/widget/export_collection_dialog.dart @@ -0,0 +1,225 @@ +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/controller/collections_controller.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/exporter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +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/snack_bar_manager.dart'; +import 'package:nc_photos/widget/processing_dialog.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'export_collection_dialog.g.dart'; +part 'export_collection_dialog/bloc.dart'; +part 'export_collection_dialog/state_event.dart'; + +class ExportCollectionDialog extends StatelessWidget { + const ExportCollectionDialog({ + super.key, + required this.account, + required this.collection, + required this.items, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + account: account, + collectionsController: + context.read().collectionsController, + collection: collection, + items: items, + ), + child: const _WrappedExportCollectionDialog(), + ); + } + + final Account account; + final Collection collection; + final List items; +} + +class _WrappedExportCollectionDialog extends StatefulWidget { + const _WrappedExportCollectionDialog(); + + @override + State createState() => _WrappedExportCollectionDialogState(); +} + +@npLog +class _WrappedExportCollectionDialogState + extends State<_WrappedExportCollectionDialog> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.result != current.result && current.result != null, + listener: _onResult, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.error != current.error, + listener: (_, state) { + if (state.error != null) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.isExporting != current.isExporting, + builder: (context, state) { + if (state.isExporting) { + return ProcessingDialog( + text: L10n.global().genericProcessingDialogContent, + ); + } else { + return AlertDialog( + title: const Text("Export collection"), + 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) { + _bloc.add(const _SubmitForm()); + } + } + + void _onResult(BuildContext context, _State state) { + Navigator.of(context).pop(state.result); + } + + 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, + ), + initialValue: context.read<_Bloc>().state.formValue.name, + 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: _ProviderOption.values + .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, + ncAlbum; + + String toValueString(BuildContext context) { + switch (this) { + case appAlbum: + return L10n.global().createCollectionDialogAlbumLabel; + case ncAlbum: + return "Nextcloud Album"; + } + } + + String toDescription(BuildContext context) { + switch (this) { + case appAlbum: + return L10n.global().createCollectionDialogAlbumDescription; + case ncAlbum: + return "Server-side album, require Nextcloud 25 or above"; + } + } +} diff --git a/app/lib/widget/export_collection_dialog.g.dart b/app/lib/widget/export_collection_dialog.g.dart new file mode 100644 index 00000000..9cae4350 --- /dev/null +++ b/app/lib/widget/export_collection_dialog.g.dart @@ -0,0 +1,113 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'export_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}); +} + +class _$_FormValueCopyWithWorkerImpl implements $_FormValueCopyWithWorker { + _$_FormValueCopyWithWorkerImpl(this.that); + + @override + _FormValue call({dynamic name, dynamic provider}) { + return _FormValue( + name: name as String? ?? that.name, + provider: provider as _ProviderOption? ?? that.provider); + } + + 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? isExporting, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic formValue, + dynamic result = copyWithNull, + dynamic isExporting, + dynamic error = copyWithNull}) { + return _State( + formValue: formValue as _FormValue? ?? that.formValue, + result: result == copyWithNull ? that.result : result as Collection?, + isExporting: isExporting as bool? ?? that.isExporting, + 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 _$_WrappedExportCollectionDialogStateNpLog + on _WrappedExportCollectionDialogState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger( + "widget.export_collection_dialog._WrappedExportCollectionDialogState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.export_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 _$_SubmitFormToString on _SubmitForm { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitForm {}"; + } +} diff --git a/app/lib/widget/export_collection_dialog/bloc.dart b/app/lib/widget/export_collection_dialog/bloc.dart new file mode 100644 index 00000000..6d2abed7 --- /dev/null +++ b/app/lib/widget/export_collection_dialog/bloc.dart @@ -0,0 +1,63 @@ +part of '../export_collection_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> { + _Bloc({ + required this.account, + required this.collectionsController, + required this.collection, + required this.items, + }) : super(_State.init()) { + on<_FormEvent>(_onFormEvent); + } + + Future _onFormEvent(_FormEvent ev, Emitter<_State> emit) async { + _log.info("$ev"); + if (ev is _SubmitName) { + _onSubmitName(ev, emit); + } else if (ev is _SubmitProvider) { + _onSubmitProvider(ev, emit); + } else if (ev is _SubmitForm) { + await _onSubmitForm(ev, emit); + } + } + + 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), + )); + } + + Future _onSubmitForm(_SubmitForm ev, Emitter<_State> emit) async { + emit(state.copyWith(isExporting: true)); + try { + final exporter = CollectionExporter(account, collectionsController, + collection, items, state.formValue.name); + final Collection result; + switch (state.formValue.provider) { + case _ProviderOption.appAlbum: + result = await exporter.asAlbum(); + break; + case _ProviderOption.ncAlbum: + result = await exporter.asNcAlbum(); + break; + } + emit(state.copyWith(result: result)); + } catch (e, stackTrace) { + _log.severe("[_onSubmitForm] Failed while exporting", e, stackTrace); + } finally { + emit(state.copyWith(isExporting: false)); + } + } + + final Account account; + final CollectionsController collectionsController; + final Collection collection; + final List items; +} diff --git a/app/lib/widget/export_collection_dialog/state_event.dart b/app/lib/widget/export_collection_dialog/state_event.dart new file mode 100644 index 00000000..bd56d8e2 --- /dev/null +++ b/app/lib/widget/export_collection_dialog/state_event.dart @@ -0,0 +1,71 @@ +part of '../export_collection_dialog.dart'; + +@genCopyWith +class _FormValue { + const _FormValue({ + this.name = "", + this.provider = _ProviderOption.appAlbum, + }); + + final String name; + final _ProviderOption provider; +} + +@genCopyWith +class _State { + const _State({ + required this.formValue, + this.result, + required this.isExporting, + this.error, + }); + + factory _State.init() { + return const _State( + formValue: _FormValue(), + isExporting: false, + ); + } + + final _FormValue formValue; + final Collection? result; + final bool isExporting; + + final ExceptionEvent? error; +} + +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 _SubmitForm extends _FormEvent { + const _SubmitForm(); + + @override + String toString() => _$toString(); +} From 19de0fa5b64a2157fd14b9c7040931daf1127dc1 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 26 Apr 2023 01:08:28 +0800 Subject: [PATCH 19/67] Tweak error handling in collection streams --- app/lib/controller/collection_items_controller.dart | 9 ++++++--- app/lib/exception_event.dart | 4 ++++ app/lib/widget/collection_browser/bloc.dart | 6 +++++- .../add_selection_to_collection_handler.dart | 13 +++---------- app/lib/widget/home_collections.dart | 8 +++----- app/lib/widget/home_collections.g.dart | 8 +++----- app/lib/widget/home_collections/bloc.dart | 3 +-- app/lib/widget/home_collections/state_event.dart | 5 ++--- 8 files changed, 27 insertions(+), 29 deletions(-) diff --git a/app/lib/controller/collection_items_controller.dart b/app/lib/controller/collection_items_controller.dart index 08f3848e..08075be5 100644 --- a/app/lib/controller/collection_items_controller.dart +++ b/app/lib/controller/collection_items_controller.dart @@ -106,8 +106,8 @@ class CollectionItemsController { return; } } - ExceptionEvent? error; + ExceptionEvent? error; final failed = []; await _mutex.protect(() async { await AddFileToCollection(_c)( @@ -165,6 +165,7 @@ class CollectionItemsController { unawaited(_load()); } }); + error?.throwMe(); } /// Remove list of [items] from [collection] @@ -180,8 +181,8 @@ class CollectionItemsController { .toList(), )); } - ExceptionEvent? error; + ExceptionEvent? error; final failed = []; await _mutex.protect(() async { await RemoveFromCollection(_c)( @@ -212,6 +213,7 @@ class CollectionItemsController { unawaited(_load()); } }); + error?.throwMe(); } /// Delete list of [files] from your server @@ -241,8 +243,8 @@ class CollectionItemsController { } else { toDelete = files; } - ExceptionEvent? error; + ExceptionEvent? error; final failed = []; await _mutex.protect(() async { await Remove(_c)( @@ -271,6 +273,7 @@ class CollectionItemsController { unawaited(_load()); } }); + error?.throwMe(); } /// Replace items in the stream, for internal use only diff --git a/app/lib/exception_event.dart b/app/lib/exception_event.dart index 43c95806..8824dc6d 100644 --- a/app/lib/exception_event.dart +++ b/app/lib/exception_event.dart @@ -4,6 +4,10 @@ class ExceptionEvent { this.stackTrace, ]); + void throwMe() { + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); + } + final Object error; final StackTrace? stackTrace; } diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index e6a6eb40..8a8ad72c 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -242,7 +242,11 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { if (selectedFiles.isNotEmpty) { final targetController = collectionsController.stream.value .itemsControllerByCollection(ev.collection); - unawaited(targetController.addFiles(selectedFiles)); + targetController.addFiles(selectedFiles).onError((e, stackTrace) { + if (e != null) { + add(_SetError(e, stackTrace)); + } + }); } } diff --git a/app/lib/widget/handler/add_selection_to_collection_handler.dart b/app/lib/widget/handler/add_selection_to_collection_handler.dart index e5a582a3..d9e97bac 100644 --- a/app/lib/widget/handler/add_selection_to_collection_handler.dart +++ b/app/lib/widget/handler/add_selection_to_collection_handler.dart @@ -14,6 +14,8 @@ import 'package:np_codegen/np_codegen.dart'; part 'add_selection_to_collection_handler.g.dart'; +/// To bridge code not yet utilizing [CollectionsController] to work with those +/// that do @npLog class AddSelectionToCollectionHandler { const AddSelectionToCollectionHandler(); @@ -38,20 +40,11 @@ class AddSelectionToCollectionHandler { .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), + content: Text(L10n.global().addToAlbumFailureNotification), duration: k.snackBarDurationNormal, )); } diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 9962dbb9..922cd7d0 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -98,13 +98,11 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> }, ), BlocListener<_Bloc, _State>( - listenWhen: (previous, current) => - previous.loadError != current.loadError, + listenWhen: (previous, current) => previous.error != current.error, listener: (context, state) { - if (state.loadError != null && isPageVisible()) { + if (state.error != null && isPageVisible()) { SnackBarManager().showSnackBar(SnackBar( - content: - Text(exception_util.toUserString(state.loadError!.error)), + content: Text(exception_util.toUserString(state.error!.error)), duration: k.snackBarDurationNormal, )); } diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index 9449109b..b0747975 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -17,9 +17,9 @@ abstract class $_StateCopyWithWorker { {List? collections, collection_util.CollectionSort? sort, bool? isLoading, - ExceptionEvent? loadError, List<_Item>? transformedItems, Set<_Item>? selectedItems, + ExceptionEvent? error, ExceptionEvent? removeError}); } @@ -31,20 +31,18 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { {dynamic collections, dynamic sort, dynamic isLoading, - dynamic loadError = copyWithNull, dynamic transformedItems, dynamic selectedItems, + dynamic error = copyWithNull, 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, + error: error == copyWithNull ? that.error : error as ExceptionEvent?, removeError: removeError == copyWithNull ? that.removeError : removeError as ExceptionEvent?); diff --git a/app/lib/widget/home_collections/bloc.dart b/app/lib/widget/home_collections/bloc.dart index 945012bb..f301bed6 100644 --- a/app/lib/widget/home_collections/bloc.dart +++ b/app/lib/widget/home_collections/bloc.dart @@ -38,13 +38,12 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { 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), + error: ExceptionEvent(e, stackTrace), ); }, ); diff --git a/app/lib/widget/home_collections/state_event.dart b/app/lib/widget/home_collections/state_event.dart index 8b3ead29..a2b94610 100644 --- a/app/lib/widget/home_collections/state_event.dart +++ b/app/lib/widget/home_collections/state_event.dart @@ -7,9 +7,9 @@ class _State { required this.collections, required this.sort, required this.isLoading, - required this.loadError, required this.transformedItems, required this.selectedItems, + this.error, required this.removeError, }); @@ -18,7 +18,6 @@ class _State { collections: [], sort: collection_util.CollectionSort.dateDescending, isLoading: false, - loadError: null, transformedItems: [], selectedItems: {}, removeError: null, @@ -31,10 +30,10 @@ class _State { final List collections; final collection_util.CollectionSort sort; final bool isLoading; - final ExceptionEvent? loadError; final List<_Item> transformedItems; final Set<_Item> selectedItems; + final ExceptionEvent? error; final ExceptionEvent? removeError; } From 71d2b566079f250ef91af90fd213d302afd05098 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 26 Apr 2023 01:26:08 +0800 Subject: [PATCH 20/67] Update generated code --- app/lib/widget/home_collections.g.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index b0747975..40b0b748 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -96,7 +96,7 @@ extension _$_ItemNpLog on _Item { 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}"; + return "_State {collections: [length: ${collections.length}], sort: ${sort.name}, isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: $selectedItems, error: $error, removeError: $removeError}"; } } From 53b51b77b14b509b7d416b930ee3f43ed8e9fa96 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 29 Apr 2023 01:21:53 +0800 Subject: [PATCH 21/67] Migrate Memory to use collection browser --- app/lib/entity/album/cover_provider.dart | 38 -- app/lib/entity/album/cover_provider.g.dart | 7 - app/lib/entity/album/provider.dart | 25 -- app/lib/entity/album/provider.g.dart | 7 - app/lib/entity/collection.dart | 31 +- app/lib/entity/collection/adapter.dart | 4 + app/lib/entity/collection/adapter/memory.dart | 59 +++ .../collection/content_provider/album.dart | 16 +- .../content_provider/location_group.dart | 16 +- .../collection/content_provider/memory.dart | 68 +++ .../collection/content_provider/memory.g.dart | 14 + .../collection/content_provider/nc_album.dart | 16 +- .../collection/content_provider/person.dart | 14 +- .../collection/content_provider/tag.dart | 15 +- app/lib/entity/nc_album.dart | 14 +- app/lib/use_case/populate_album.dart | 24 -- app/lib/widget/album_browser_util.dart | 4 - .../builder/photo_list_item_builder.dart | 10 +- app/lib/widget/collection_browser/bloc.dart | 24 +- app/lib/widget/home_photos.dart | 44 +- app/lib/widget/my_app.dart | 16 - app/lib/widget/photo_list_util.dart | 41 +- app/lib/widget/photo_list_util.g.dart | 4 +- app/lib/widget/smart_album_browser.dart | 403 ------------------ app/lib/widget/smart_album_browser.g.dart | 26 -- app/test/widget/photo_list_util_test.dart | 192 ++++----- 26 files changed, 392 insertions(+), 740 deletions(-) create mode 100644 app/lib/entity/collection/adapter/memory.dart create mode 100644 app/lib/entity/collection/content_provider/memory.dart create mode 100644 app/lib/entity/collection/content_provider/memory.g.dart delete mode 100644 app/lib/widget/smart_album_browser.dart delete mode 100644 app/lib/widget/smart_album_browser.g.dart diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index fd660278..20a718df 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -26,9 +26,6 @@ abstract class AlbumCoverProvider with EquatableMixin { case AlbumManualCoverProvider._type: return AlbumManualCoverProvider.fromJson( content.cast()); - case AlbumMemoryCoverProvider._type: - return AlbumMemoryCoverProvider.fromJson( - content.cast()); default: _log.shout("[fromJson] Unknown type: $type"); throw ArgumentError.value(type, "type"); @@ -154,38 +151,3 @@ class AlbumManualCoverProvider extends AlbumCoverProvider { static const _type = "manual"; } - -/// Cover selected when building a Memory album -@toString -class AlbumMemoryCoverProvider extends AlbumCoverProvider { - AlbumMemoryCoverProvider({ - required this.coverFile, - }); - - factory AlbumMemoryCoverProvider.fromJson(JsonObj json) { - return AlbumMemoryCoverProvider( - coverFile: - FileDescriptor.fromJson(json["coverFile"].cast()), - ); - } - - @override - String toString() => _$toString(); - - @override - getCover(Album album) => coverFile; - - @override - get props => [ - coverFile, - ]; - - @override - _toContentJson() => { - "coverFile": coverFile.toFdJson(), - }; - - final FileDescriptor coverFile; - - static const _type = "memory"; -} diff --git a/app/lib/entity/album/cover_provider.g.dart b/app/lib/entity/album/cover_provider.g.dart index d958ec48..0521f48f 100644 --- a/app/lib/entity/album/cover_provider.g.dart +++ b/app/lib/entity/album/cover_provider.g.dart @@ -30,10 +30,3 @@ extension _$AlbumManualCoverProviderToString on AlbumManualCoverProvider { return "AlbumManualCoverProvider {coverFile: ${coverFile.fdPath}}"; } } - -extension _$AlbumMemoryCoverProviderToString on AlbumMemoryCoverProvider { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "AlbumMemoryCoverProvider {coverFile: ${coverFile.fdPath}}"; - } -} diff --git a/app/lib/entity/album/provider.dart b/app/lib/entity/album/provider.dart index 18ab269b..1d47a5d0 100644 --- a/app/lib/entity/album/provider.dart +++ b/app/lib/entity/album/provider.dart @@ -284,28 +284,3 @@ abstract class AlbumSmartProvider extends AlbumProviderBase { throw UnimplementedError(); } } - -/// Memory album is created based on dates -@ToString(extraParams: r"{bool isDeep = false}") -class AlbumMemoryProvider extends AlbumSmartProvider { - AlbumMemoryProvider({ - required this.year, - required this.month, - required this.day, - }) : super(latestItemTime: DateTime(year, month, day)); - - @override - String toString({bool isDeep = false}) => _$toString(isDeep: isDeep); - - @override - get props => [ - ...super.props, - year, - month, - day, - ]; - - final int year; - final int month; - final int day; -} diff --git a/app/lib/entity/album/provider.g.dart b/app/lib/entity/album/provider.g.dart index 3c4a0f2d..1fe71e9b 100644 --- a/app/lib/entity/album/provider.g.dart +++ b/app/lib/entity/album/provider.g.dart @@ -37,10 +37,3 @@ extension _$AlbumTagProviderToString on AlbumTagProvider { return "AlbumTagProvider {latestItemTime: $latestItemTime, tags: ${tags.map((t) => t.displayName).toReadableString()}}"; } } - -extension _$AlbumMemoryProviderToString on AlbumMemoryProvider { - String _$toString({bool isDeep = false}) { - // ignore: unnecessary_string_interpolations - return "AlbumMemoryProvider {latestItemTime: $latestItemTime, year: $year, month: $month, day: $day}"; - } -} diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index ddec018e..a9bdca0f 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -1,4 +1,5 @@ import 'package:copy_with/copy_with.dart'; +import 'package:equatable/equatable.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'; @@ -8,7 +9,7 @@ part 'collection.g.dart'; /// Describe a group of items @genCopyWith @toString -class Collection { +class Collection with EquatableMixin { const Collection({ required this.name, required this.contentProvider, @@ -40,11 +41,25 @@ class Collection { CollectionItemSort get itemSort => contentProvider.itemSort; /// See [CollectionContentProvider.getCoverUrl] - String? getCoverUrl(int width, int height) => - contentProvider.getCoverUrl(width, height); + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) => + contentProvider.getCoverUrl( + width, + height, + isKeepAspectRatio: isKeepAspectRatio, + ); CollectionSorter getSorter() => CollectionSorter.fromSortType(itemSort); + @override + List get props => [ + name, + contentProvider, + ]; + final String name; final CollectionContentProvider contentProvider; } @@ -65,7 +80,7 @@ enum CollectionCapability { } /// Provide the actual content of a collection -abstract class CollectionContentProvider { +abstract class CollectionContentProvider with EquatableMixin { const CollectionContentProvider(); /// Unique FourCC of this provider type @@ -95,5 +110,11 @@ abstract class CollectionContentProvider { /// /// 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); + /// + /// [isKeepAspectRatio] is only a hint and implementations may ignore it + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }); } diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index 87c89d70..db703281 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -4,11 +4,13 @@ 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/memory.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/memory.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'; @@ -29,6 +31,8 @@ abstract class CollectionAdapter { return CollectionAlbumAdapter(c, account, collection); case CollectionLocationGroupProvider: return CollectionLocationGroupAdapter(c, account, collection); + case CollectionMemoryProvider: + return CollectionMemoryAdapter(c, account, collection); case CollectionNcAlbumProvider: return CollectionNcAlbumAdapter(c, account, collection); case CollectionPersonProvider: diff --git a/app/lib/entity/collection/adapter/memory.dart b/app/lib/entity/collection/adapter/memory.dart new file mode 100644 index 00000000..53a16a4d --- /dev/null +++ b/app/lib/entity/collection/adapter/memory.dart @@ -0,0 +1,59 @@ +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/memory.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/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/use_case/list_location_file.dart'; + +class CollectionMemoryAdapter + with CollectionReadOnlyAdapter + implements CollectionAdapter { + CollectionMemoryAdapter(this._c, this.account, this.collection) + : assert(require(_c)), + _provider = collection.contentProvider as CollectionMemoryProvider; + + static bool require(DiContainer c) => ListLocationFile.require(c); + + @override + Stream> listItem() async* { + final date = DateTime(_provider.year, _provider.month, _provider.day); + final dayRange = _c.pref.getMemoriesRangeOr(); + final from = date.subtract(Duration(days: dayRange)); + final to = date.add(Duration(days: dayRange + 1)); + final files = await FileSqliteDbDataSource(_c).listByDate( + account, from.millisecondsSinceEpoch, to.millisecondsSinceEpoch); + 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"); + } + + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionMemoryProvider _provider; +} diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index 0578dd70..9914fa90 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -1,4 +1,5 @@ import 'package:copy_with/copy_with.dart'; +import 'package:equatable/equatable.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'; @@ -12,7 +13,9 @@ part 'album.g.dart'; /// Album provided by our app @genCopyWith @toString -class CollectionAlbumProvider implements CollectionContentProvider { +class CollectionAlbumProvider + with EquatableMixin + implements CollectionContentProvider { const CollectionAlbumProvider({ required this.account, required this.album, @@ -64,7 +67,11 @@ class CollectionAlbumProvider implements CollectionContentProvider { CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort(); @override - String? getCoverUrl(int width, int height) { + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) { final fd = album.coverProvider.getCover(album); if (fd == null) { return null; @@ -74,11 +81,14 @@ class CollectionAlbumProvider implements CollectionContentProvider { fd.fdId, width: width, height: height, - isKeepAspectRatio: false, + isKeepAspectRatio: isKeepAspectRatio ?? false, ); } } + @override + List get props => [account, album]; + final Account account; final Album album; } diff --git a/app/lib/entity/collection/content_provider/location_group.dart b/app/lib/entity/collection/content_provider/location_group.dart index ff96e1a0..6316eb99 100644 --- a/app/lib/entity/collection/content_provider/location_group.dart +++ b/app/lib/entity/collection/content_provider/location_group.dart @@ -1,10 +1,13 @@ +import 'package:equatable/equatable.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/use_case/list_location_group.dart'; -class CollectionLocationGroupProvider implements CollectionContentProvider { +class CollectionLocationGroupProvider + with EquatableMixin + implements CollectionContentProvider { const CollectionLocationGroupProvider({ required this.account, required this.location, @@ -29,16 +32,23 @@ class CollectionLocationGroupProvider implements CollectionContentProvider { CollectionItemSort get itemSort => CollectionItemSort.dateDescending; @override - String? getCoverUrl(int width, int height) { + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) { return api_util.getFilePreviewUrlByFileId( account, location.latestFileId, width: width, height: height, - isKeepAspectRatio: false, + isKeepAspectRatio: isKeepAspectRatio ?? false, ); } + @override + List get props => [account, location]; + final Account account; final LocationGroup location; } diff --git a/app/lib/entity/collection/content_provider/memory.dart b/app/lib/entity/collection/content_provider/memory.dart new file mode 100644 index 00000000..c8ed13c5 --- /dev/null +++ b/app/lib/entity/collection/content_provider/memory.dart @@ -0,0 +1,68 @@ +import 'package:equatable/equatable.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/file_descriptor.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:to_string/to_string.dart'; + +part 'memory.g.dart'; + +@toString +class CollectionMemoryProvider + with EquatableMixin + implements CollectionContentProvider { + const CollectionMemoryProvider({ + required this.account, + required this.year, + required this.month, + required this.day, + this.cover, + }); + + @override + String get fourCc => "MEMY"; + + @override + String get id => "$year-$month-$day"; + + @override + int? get count => null; + + @override + DateTime get lastModified => DateTime(year, month, day); + + @override + List get capabilities => []; + + @override + CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + + @override + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) { + return cover?.run((cover) => api_util.getFilePreviewUrl( + account, + cover, + width: width, + height: height, + isKeepAspectRatio: isKeepAspectRatio ?? false, + )); + } + + @override + String toString() => _$toString(); + + @override + List get props => [account, year, month, day, cover]; + + final Account account; + final int year; + final int month; + final int day; + final FileDescriptor? cover; +} diff --git a/app/lib/entity/collection/content_provider/memory.g.dart b/app/lib/entity/collection/content_provider/memory.g.dart new file mode 100644 index 00000000..df768f45 --- /dev/null +++ b/app/lib/entity/collection/content_provider/memory.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'memory.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionMemoryProviderToString on CollectionMemoryProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionMemoryProvider {account: $account, year: $year, month: $month, day: $day, cover: ${cover == null ? null : "${cover!.fdPath}"}}"; + } +} diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index 5d537cea..cd22d7fb 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -1,5 +1,6 @@ import 'package:clock/clock.dart'; import 'package:copy_with/copy_with.dart'; +import 'package:equatable/equatable.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'; @@ -12,7 +13,9 @@ part 'nc_album.g.dart'; /// Album provided by our app @genCopyWith @toString -class CollectionNcAlbumProvider implements CollectionContentProvider { +class CollectionNcAlbumProvider + with EquatableMixin + implements CollectionContentProvider { const CollectionNcAlbumProvider({ required this.account, required this.album, @@ -43,7 +46,11 @@ class CollectionNcAlbumProvider implements CollectionContentProvider { CollectionItemSort get itemSort => CollectionItemSort.dateDescending; @override - String? getCoverUrl(int width, int height) { + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) { if (album.lastPhoto == null) { return null; } else { @@ -52,11 +59,14 @@ class CollectionNcAlbumProvider implements CollectionContentProvider { album.lastPhoto!, width: width, height: height, - isKeepAspectRatio: false, + isKeepAspectRatio: isKeepAspectRatio ?? false, ); } } + @override + List get props => [account, album]; + final Account account; final NcAlbum album; } diff --git a/app/lib/entity/collection/content_provider/person.dart b/app/lib/entity/collection/content_provider/person.dart index ef3ce1cd..16e7efae 100644 --- a/app/lib/entity/collection/content_provider/person.dart +++ b/app/lib/entity/collection/content_provider/person.dart @@ -1,13 +1,16 @@ import 'dart:math' as math; import 'package:clock/clock.dart'; +import 'package:equatable/equatable.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 { +class CollectionPersonProvider + with EquatableMixin + implements CollectionContentProvider { const CollectionPersonProvider({ required this.account, required this.person, @@ -32,11 +35,18 @@ class CollectionPersonProvider implements CollectionContentProvider { CollectionItemSort get itemSort => CollectionItemSort.dateDescending; @override - String? getCoverUrl(int width, int height) { + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) { return api_util.getFacePreviewUrl(account, person.thumbFaceId, size: math.max(width, height)); } + @override + List get props => [account, person]; + 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 index ab4b415e..23bc98f8 100644 --- a/app/lib/entity/collection/content_provider/tag.dart +++ b/app/lib/entity/collection/content_provider/tag.dart @@ -1,10 +1,13 @@ import 'package:clock/clock.dart'; +import 'package:equatable/equatable.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 { +class CollectionTagProvider + with EquatableMixin + implements CollectionContentProvider { CollectionTagProvider({ required this.account, required this.tags, @@ -29,7 +32,15 @@ class CollectionTagProvider implements CollectionContentProvider { CollectionItemSort get itemSort => CollectionItemSort.dateDescending; @override - String? getCoverUrl(int width, int height) => null; + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) => + null; + + @override + List get props => [account, tags]; final Account account; final List tags; diff --git a/app/lib/entity/nc_album.dart b/app/lib/entity/nc_album.dart index 82812b06..9250c967 100644 --- a/app/lib/entity/nc_album.dart +++ b/app/lib/entity/nc_album.dart @@ -1,4 +1,5 @@ import 'package:copy_with/copy_with.dart'; +import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:np_common/string_extension.dart'; import 'package:to_string/to_string.dart'; @@ -8,7 +9,7 @@ part 'nc_album.g.dart'; /// Server-side album since Nextcloud 25 @toString @genCopyWith -class NcAlbum { +class NcAlbum with EquatableMixin { NcAlbum({ required String path, required this.lastPhoto, @@ -37,6 +38,17 @@ class NcAlbum { @override String toString() => _$toString(); + @override + List get props => [ + path, + lastPhoto, + nbItems, + location, + dateStart, + dateEnd, + collaborators, + ]; + final String path; /// File ID of the last photo diff --git a/app/lib/use_case/populate_album.dart b/app/lib/use_case/populate_album.dart index 6d2d5b24..6b020a49 100644 --- a/app/lib/use_case/populate_album.dart +++ b/app/lib/use_case/populate_album.dart @@ -8,8 +8,6 @@ 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.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/use_case/list_tagged_file.dart'; import 'package:nc_photos/use_case/scan_dir.dart'; @@ -32,8 +30,6 @@ class PopulateAlbum { return _populateDirAlbum(account, album); } else if (album.provider is AlbumTagProvider) { return _populateTagAlbum(account, album); - } else if (album.provider is AlbumMemoryProvider) { - return _populateMemoryAlbum(account, album); } else { throw ArgumentError( "Unknown album provider: ${album.provider.runtimeType}"); @@ -80,25 +76,5 @@ class PopulateAlbum { return products; } - Future> _populateMemoryAlbum( - Account account, Album album) async { - assert(album.provider is AlbumMemoryProvider); - final provider = album.provider as AlbumMemoryProvider; - final date = DateTime(provider.year, provider.month, provider.day); - final dayRange = _c.pref.getMemoriesRangeOr(); - final from = date.subtract(Duration(days: dayRange)); - final to = date.add(Duration(days: dayRange + 1)); - final files = await FileSqliteDbDataSource(_c).listByDate( - account, from.millisecondsSinceEpoch, to.millisecondsSinceEpoch); - return files - .where((f) => file_util.isSupportedFormat(f)) - .map((f) => AlbumFileItem( - addedBy: account.userId, - addedAt: clock.now(), - file: f, - )) - .toList(); - } - final DiContainer _c; } diff --git a/app/lib/widget/album_browser_util.dart b/app/lib/widget/album_browser_util.dart index 8ecb12f4..11bd502d 100644 --- a/app/lib/widget/album_browser_util.dart +++ b/app/lib/widget/album_browser_util.dart @@ -3,16 +3,12 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/widget/album_browser.dart'; -import 'package:nc_photos/widget/smart_album_browser.dart'; /// Push the corresponding browser route for this album void push(BuildContext context, Account account, Album album) { if (album.provider is AlbumStaticProvider) { Navigator.of(context).pushNamed(AlbumBrowser.routeName, arguments: AlbumBrowserArguments(account, album)); - } else if (album.provider is AlbumSmartProvider) { - Navigator.of(context).pushNamed(SmartAlbumBrowser.routeName, - arguments: SmartAlbumBrowserArguments(account, album)); } } diff --git a/app/lib/widget/builder/photo_list_item_builder.dart b/app/lib/widget/builder/photo_list_item_builder.dart index 738a4eac..0dcf9ebd 100644 --- a/app/lib/widget/builder/photo_list_item_builder.dart +++ b/app/lib/widget/builder/photo_list_item_builder.dart @@ -5,7 +5,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_init.dart' as app_init; import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/object_extension.dart'; @@ -49,12 +49,12 @@ class PhotoListItemBuilderResult { const PhotoListItemBuilderResult( this.backingFiles, this.listItems, { - this.smartAlbums = const [], + this.smartCollections = const [], }); final List backingFiles; final List listItems; - final List smartAlbums; + final List smartCollections; } typedef PhotoListItemSorter = int Function(FileDescriptor, FileDescriptor); @@ -137,7 +137,7 @@ class _PhotoListItemBuilder { Account account, List files) { final today = clock.now(); final memoryAlbumHelper = smartAlbumConfig != null - ? MemoryAlbumHelper( + ? MemoryCollectionHelper(account, today: today, dayRange: smartAlbumConfig!.memoriesDayRange) : null; final listItems = []; @@ -155,7 +155,7 @@ class _PhotoListItemBuilder { return PhotoListItemBuilderResult( files, listItems, - smartAlbums: smartAlbums ?? [], + smartCollections: smartAlbums ?? [], ); } diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index 8a8ad72c..a5f0ffdc 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -8,6 +8,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { required this.collectionsController, required Collection collection, }) : _c = container, + _isAdHocCollection = !collectionsController.stream.value.data + .any((e) => e.collection.compareIdentity(collection)), super(_State.init( collection: collection, coverUrl: _getCoverUrl(collection), @@ -45,14 +47,16 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { 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)); - } - }); + if (!_isAdHocCollection) { + _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) { @@ -448,6 +452,10 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { final CollectionsController collectionsController; late final CollectionItemsController itemsController; + /// Specify if the supplied [collection] is an "inline" one, which means it's + /// not returned from the collection controller but rather created temporarily + final bool _isAdHocCollection; + StreamSubscription? _collectionControllerSubscription; StreamSubscription? _itemsControllerSubscription; } diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index 1b644e07..aa6294d8 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -18,7 +18,7 @@ import 'package:nc_photos/bloc/scan_account_dir.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/album.dart'; +import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/event/event.dart'; @@ -38,19 +38,18 @@ 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/builder/photo_list_item_builder.dart'; +import 'package:nc_photos/widget/collection_browser.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'; import 'package:nc_photos/widget/home_app_bar.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_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'; @@ -192,7 +191,7 @@ class _HomePhotosState extends State _web?.buildContent(context), if (AccountPref.of(widget.account) .isEnableMemoryAlbumOr(true) && - _smartAlbums.isNotEmpty) + _smartCollections.isNotEmpty) _buildSmartAlbumList(context), BlocBuilder( bloc: _bloc, @@ -352,9 +351,9 @@ class _HomePhotosState extends State Widget _buildSmartAlbumList(BuildContext context) { return SliverToBoxAdapter( - child: _SmartAlbumList( + child: _SmartCollectionList( account: widget.account, - albums: _smartAlbums, + collections: _smartCollections, ), ); } @@ -613,7 +612,7 @@ class _HomePhotosState extends State setState(() { _backingFiles = result.backingFiles; itemStreamListItems = result.listItems; - _smartAlbums = result.smartAlbums; + _smartCollections = result.smartCollections; if (isPostSuccess) { _isScrollbarVisible = true; @@ -680,7 +679,7 @@ class _HomePhotosState extends State final metadataTaskHeaderExtent = _web?.getHeaderHeight() ?? 0; final smartAlbumListHeight = AccountPref.of(widget.account).isEnableMemoryAlbumOr(true) && - _smartAlbums.isNotEmpty + _smartCollections.isNotEmpty ? _SmartAlbumItem.height : 0; // scroll extent = list height - widget viewport height @@ -745,7 +744,7 @@ class _HomePhotosState extends State late final _queryProgressBloc = ProgressBloc(); var _backingFiles = []; - var _smartAlbums = []; + var _smartCollections = []; final _buildItemQueue = ComputeQueue(); @@ -1006,10 +1005,10 @@ class _MetadataTaskLoadingIcon extends AnimatedWidget { Animation get _progress => listenable as Animation; } -class _SmartAlbumList extends StatelessWidget { - const _SmartAlbumList({ +class _SmartCollectionList extends StatelessWidget { + const _SmartCollectionList({ required this.account, - required this.albums, + required this.collections, }); @override @@ -1019,19 +1018,20 @@ class _SmartAlbumList extends StatelessWidget { child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: albums.length, + itemCount: collections.length, itemBuilder: (context, index) { - final a = albums[index]; - final coverFile = a.coverProvider.getCover(a); + final c = collections[index]; return _SmartAlbumItem( account: account, - previewUrl: coverFile == null - ? null - : NetworkRectThumbnail.imageUrlForFile(account, coverFile), - label: a.name, + previewUrl: c.getCoverUrl( + k.photoThumbSize, + k.photoThumbSize, + isKeepAspectRatio: true, + ), + label: c.name, onTap: () { - Navigator.of(context).pushNamed(SmartAlbumBrowser.routeName, - arguments: SmartAlbumBrowserArguments(account, a)); + Navigator.of(context).pushNamed(CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(c)); }, ); }, @@ -1041,7 +1041,7 @@ class _SmartAlbumList extends StatelessWidget { } final Account account; - final List albums; + final List collections; } class _SmartAlbumItem extends StatelessWidget { diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 83ba0a8b..51d86e01 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -39,7 +39,6 @@ import 'package:nc_photos/widget/shared_file_viewer.dart'; import 'package:nc_photos/widget/sharing_browser.dart'; 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/trashbin_browser.dart'; import 'package:nc_photos/widget/trashbin_viewer.dart'; @@ -195,7 +194,6 @@ class _WrappedAppState extends State<_WrappedApp> route ??= _handleAlbumShareOutlierBrowserRoute(settings); route ??= _handleAccountSettingsRoute(settings); route ??= _handleShareFolderPickerRoute(settings); - route ??= _handleSmartAlbumBrowserRoute(settings); route ??= _handleEnhancedPhotoBrowserRoute(settings); route ??= _handleLocalFileViewerRoute(settings); route ??= _handleEnhancementSettingsRoute(settings); @@ -455,20 +453,6 @@ class _WrappedAppState extends State<_WrappedApp> return null; } - Route? _handleSmartAlbumBrowserRoute(RouteSettings settings) { - try { - if (settings.name == SmartAlbumBrowser.routeName && - settings.arguments != null) { - final args = settings.arguments as SmartAlbumBrowserArguments; - return SmartAlbumBrowser.buildRoute(args); - } - } catch (e) { - _log.severe( - "[_handleSmartAlbumBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handleEnhancedPhotoBrowserRoute(RouteSettings settings) { try { if (settings.name == EnhancedPhotoBrowser.routeName && diff --git a/app/lib/widget/photo_list_util.dart b/app/lib/widget/photo_list_util.dart index 0bbf2b69..bf71a50c 100644 --- a/app/lib/widget/photo_list_util.dart +++ b/app/lib/widget/photo_list_util.dart @@ -3,11 +3,10 @@ import 'dart:math' as math; import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; import 'package:nc_photos/date_time_extension.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/memory.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -34,12 +33,13 @@ class DateGroupHelper { DateTime? _currentDate; } -/// Build memory album from files +/// Build memory collection from files /// /// Feb 29 is treated as Mar 1 on non leap years @npLog -class MemoryAlbumHelper { - MemoryAlbumHelper({ +class MemoryCollectionHelper { + MemoryCollectionHelper( + this.account, { DateTime? today, required int dayRange, }) : today = (today?.toLocal() ?? clock.now()).toMidnight(), @@ -65,16 +65,18 @@ class MemoryAlbumHelper { /// /// [nameBuilder] is a function that return the name of the album for a /// particular year - List build(String Function(int year) nameBuilder) { + List build(String Function(int year) nameBuilder) { return _data.entries .sorted((a, b) => b.key.compareTo(a.key)) - .map((e) => Album( + .map((e) => Collection( name: nameBuilder(e.key), - provider: AlbumMemoryProvider( - year: e.key, month: today.month, day: today.day), - coverProvider: - AlbumMemoryCoverProvider(coverFile: e.value.coverFile), - sortProvider: const AlbumTimeSortProvider(isAscending: false), + contentProvider: CollectionMemoryProvider( + account: account, + year: e.key, + month: today.month, + day: today.day, + cover: e.value.coverFile, + ), )) .toList(); } @@ -83,9 +85,9 @@ class MemoryAlbumHelper { final item = _data[year]; final date = today.copyWith(year: year); if (item == null) { - _data[year] = _MemoryAlbumHelperItem(date, f); + _data[year] = _MemoryCollectionHelperItem(date, f); } else { - final coverDiff = _MemoryAlbumHelperItem.getCoverDiff(date, f); + final coverDiff = _MemoryCollectionHelperItem.getCoverDiff(date, f); if (coverDiff < item.coverDiff) { item.coverFile = f; item.coverDiff = coverDiff; @@ -93,9 +95,10 @@ class MemoryAlbumHelper { } } + final Account account; final DateTime today; final int dayRange; - final _data = {}; + final _data = {}; } int getThumbSize(int zoomLevel) { @@ -115,8 +118,8 @@ int getThumbSize(int zoomLevel) { } } -class _MemoryAlbumHelperItem { - _MemoryAlbumHelperItem(this.date, this.coverFile) +class _MemoryCollectionHelperItem { + _MemoryCollectionHelperItem(this.date, this.coverFile) : coverDiff = getCoverDiff(date, coverFile); static Duration getCoverDiff(DateTime date, FileDescriptor f) => diff --git a/app/lib/widget/photo_list_util.g.dart b/app/lib/widget/photo_list_util.g.dart index 79bfa43a..3607365c 100644 --- a/app/lib/widget/photo_list_util.g.dart +++ b/app/lib/widget/photo_list_util.g.dart @@ -6,9 +6,9 @@ part of 'photo_list_util.dart'; // NpLogGenerator // ************************************************************************** -extension _$MemoryAlbumHelperNpLog on MemoryAlbumHelper { +extension _$MemoryCollectionHelperNpLog on MemoryCollectionHelper { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.photo_list_util.MemoryAlbumHelper"); + static final log = Logger("widget.photo_list_util.MemoryCollectionHelper"); } diff --git a/app/lib/widget/smart_album_browser.dart b/app/lib/widget/smart_album_browser.dart deleted file mode 100644 index 4ddf18be..00000000 --- a/app/lib/widget/smart_album_browser.dart +++ /dev/null @@ -1,403 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.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/di_container.dart'; -import 'package:nc_photos/download_handler.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.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/flutter_util.dart' as flutter_util; -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_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'; -import 'package:nc_photos/widget/viewer.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'smart_album_browser.g.dart'; - -class SmartAlbumBrowserArguments { - const SmartAlbumBrowserArguments(this.account, this.album); - - final Account account; - final Album album; -} - -class SmartAlbumBrowser extends StatefulWidget { - static const routeName = "/smart-album-browser"; - - static Route buildRoute(SmartAlbumBrowserArguments args) => MaterialPageRoute( - builder: (context) => SmartAlbumBrowser.fromArgs(args), - ); - - const SmartAlbumBrowser({ - Key? key, - required this.account, - required this.album, - }) : super(key: key); - - SmartAlbumBrowser.fromArgs(SmartAlbumBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - album: args.album, - ); - - @override - createState() => _SmartAlbumBrowserState(); - - final Account account; - final Album album; -} - -@npLog -class _SmartAlbumBrowserState extends State - with - SelectableItemStreamListMixin, - AlbumBrowserMixin { - _SmartAlbumBrowserState() { - final c = KiwiContainer().resolve(); - assert(PreProcessAlbum.require(c)); - _c = c; - } - - @override - initState() { - super.initState(); - _initAlbum(); - } - - @override - build(BuildContext context) { - return Scaffold( - body: Builder( - builder: (context) => _buildContent(context), - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as<_ListItem>()?.onTap?.call(); - } - - @override - @protected - get canEdit => false; - - Future _initAlbum() async { - assert(widget.album.provider is AlbumSmartProvider); - _log.info("[_initAlbum] ${widget.album}"); - final items = await PreProcessAlbum(_c)(widget.account, widget.album); - if (mounted) { - setState(() { - _album = widget.album; - _transformItems(items); - initCover(widget.account, widget.album); - }); - } - } - - Widget _buildContent(BuildContext context) { - if (_album == null) { - return CustomScrollView( - slivers: [ - buildNormalAppBar(context, widget.account, widget.album), - const SliverToBoxAdapter( - child: LinearProgressIndicator(), - ), - ], - ); - } else { - return buildItemStreamListOuter( - context, - child: CustomScrollView( - slivers: [ - _buildAppBar(context), - buildItemStreamList( - maxCrossAxisExtent: thumbSize.toDouble(), - ), - ], - ), - ); - } - } - - Widget _buildAppBar(BuildContext context) { - if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return _buildNormalAppBar(context); - } - } - - Widget _buildNormalAppBar(BuildContext context) { - final menuItems = >[ - PopupMenuItem( - value: _menuValueDownload, - child: Text(L10n.global().downloadTooltip), - ), - ]; - - return buildNormalAppBar( - context, - widget.account, - _album!, - menuItemBuilder: (_) => menuItems, - onSelectedMenuItem: (option) { - switch (option) { - case _menuValueDownload: - _onDownloadPressed(); - break; - default: - _log.shout("[_buildNormalAppBar] Unknown value: $option"); - break; - } - }, - ); - } - - Widget _buildSelectionAppBar(BuildContext context) { - return buildSelectionAppBar(context, [ - IconButton( - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - onPressed: () { - _onSelectionSharePressed(context); - }, - ), - IconButton( - icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, - onPressed: () => _onSelectionAddPressed(context), - ), - PopupMenuButton<_SelectionMenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - PopupMenuItem( - value: _SelectionMenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - ], - onSelected: (option) => _onSelectionMenuSelected(context, option), - ), - ]); - } - - void _onItemTap(int index) { - // convert item index to file index - var fileIndex = index; - for (int i = 0; i < index; ++i) { - if (_sortedItems[i] is! AlbumFileItem || - !file_util - .isSupportedFormat((_sortedItems[i] as AlbumFileItem).file)) { - --fileIndex; - } - } - Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); - } - - void _onDownloadPressed() { - final c = KiwiContainer().resolve(); - DownloadHandler(c).downloadFiles( - widget.account, - _sortedItems.whereType().map((e) => e.file).toList(), - parentDir: _album!.name, - ); - } - - void _onSelectionMenuSelected( - BuildContext context, _SelectionMenuOption option) { - switch (option) { - 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<_FileListItem>() - .map((e) => e.file) - .toList(); - ShareHandler( - c, - context: context, - clearSelection: () { - setState(() { - clearSelectedItems(); - }); - }, - ).shareFiles(widget.account, selected); - } - - Future _onSelectionAddPressed(BuildContext context) async { - return const AddSelectionToCollectionHandler()( - context: context, - selection: selectedListItems - .whereType<_FileListItem>() - .map((e) => e.file) - .toList(), - clearSelection: () { - if (mounted) { - setState(() { - clearSelectedItems(); - }); - } - }, - ); - } - - void _onSelectionDownloadPressed() { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType<_FileListItem>() - .map((e) => e.file) - .toList(); - DownloadHandler(c).downloadFiles(widget.account, selected); - setState(() { - clearSelectedItems(); - }); - } - - void _transformItems(List items) { - // items come sorted for smart album - _sortedItems = _album!.sortProvider.sort(items); - _backingFiles = _sortedItems - .whereType() - .map((i) => i.file) - .where((f) => file_util.isSupportedFormat(f)) - .toList(); - - itemStreamListItems = () sync* { - for (int i = 0; i < _sortedItems.length; ++i) { - final item = _sortedItems[i]; - if (item is AlbumFileItem) { - final previewUrl = - NetworkRectThumbnail.imageUrlForFile(widget.account, item.file); - if (file_util.isSupportedImageFormat(item.file)) { - yield _ImageListItem( - index: i, - file: item.file, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - } else if (file_util.isSupportedVideoFormat(item.file)) { - yield _VideoListItem( - index: i, - file: item.file, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - ); - } - } - } - }() - .toList(); - } - - late final DiContainer _c; - - Album? _album; - var _sortedItems = []; - var _backingFiles = []; - - static const _menuValueDownload = 1; -} - -enum _SelectionMenuOption { - download, -} - -@toString -abstract class _ListItem implements SelectableItem { - const _ListItem({ - required this.index, - this.onTap, - }); - - @override - get isTappable => onTap != null; - - @override - get isSelectable => true; - - @override - get staggeredTile => const StaggeredTile.count(1, 1); - - @override - String toString() => _$toString(); - - final int index; - - @ignore - final VoidCallback? onTap; -} - -abstract class _FileListItem extends _ListItem { - _FileListItem({ - required super.index, - required this.file, - super.onTap, - }); - - final File file; -} - -class _ImageListItem extends _FileListItem { - _ImageListItem({ - required super.index, - required super.file, - required this.account, - required this.previewUrl, - super.onTap, - }); - - @override - buildWidget(BuildContext context) => PhotoListImage( - account: account, - previewUrl: previewUrl, - isGif: file.contentType == "image/gif", - heroKey: flutter_util.getImageHeroTag(file), - ); - - final Account account; - final String previewUrl; -} - -class _VideoListItem extends _FileListItem { - _VideoListItem({ - required super.index, - required super.file, - required this.account, - required this.previewUrl, - super.onTap, - }); - - @override - buildWidget(BuildContext context) => PhotoListVideo( - account: account, - previewUrl: previewUrl, - ); - - final Account account; - final String previewUrl; -} diff --git a/app/lib/widget/smart_album_browser.g.dart b/app/lib/widget/smart_album_browser.g.dart deleted file mode 100644 index bf5026a3..00000000 --- a/app/lib/widget/smart_album_browser.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'smart_album_browser.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_SmartAlbumBrowserStateNpLog on _SmartAlbumBrowserState { - // ignore: unused_element - Logger get _log => log; - - static final log = - Logger("widget.smart_album_browser._SmartAlbumBrowserState"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$_ListItemToString on _ListItem { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "_ListItem")} {index: $index}"; - } -} diff --git a/app/test/widget/photo_list_util_test.dart b/app/test/widget/photo_list_util_test.dart index bea7e9b3..f339e593 100644 --- a/app/test/widget/photo_list_util_test.dart +++ b/app/test/widget/photo_list_util_test.dart @@ -1,15 +1,12 @@ -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/or_null.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/memory.dart'; import 'package:nc_photos/widget/photo_list_util.dart'; import 'package:test/test.dart'; import '../test_util.dart' as util; void main() { - group("MemoryAlbumHelper", () { + group("MemoryCollectionHelper", () { test("same year", _sameYear); test("next year", _nextYear); group("prev year", () { @@ -54,8 +51,9 @@ void main() { /// File: 2021-02-01 /// Expect: empty void _sameYear() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2021, 2, 3)); obj.addFile(file); @@ -69,8 +67,9 @@ void _sameYear() { /// File: 2022-02-03 /// Expect: empty void _nextYear() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2022, 2, 3)); obj.addFile(file); @@ -83,24 +82,19 @@ void _nextYear() { /// File: 2020-02-03 /// Expect: [2020] void _prevYear() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 3)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2020", - provider: - AlbumMemoryProvider(year: 2020, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2020, month: 2, day: 3, cover: file), ), ], ); @@ -112,8 +106,9 @@ void _prevYear() { /// File: 2020-01-31 /// Expect: empty void _prevYear3DaysBefore() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2020, 1, 31)); obj.addFile(file); @@ -126,24 +121,19 @@ void _prevYear3DaysBefore() { /// File: 2020-02-01 /// Expect: [2020] void _prevYear2DaysBefore() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 1)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2020", - provider: - AlbumMemoryProvider(year: 2020, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2020, month: 2, day: 3, cover: file), ), ], ); @@ -155,8 +145,9 @@ void _prevYear2DaysBefore() { /// File: 2020-02-06 /// Expect: empty void _prevYear3DaysAfter() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 6)); obj.addFile(file); @@ -169,24 +160,19 @@ void _prevYear3DaysAfter() { /// File: 2020-02-05 /// Expect: [2020] void _prevYear2DaysAfter() { + final account = util.buildAccount(); final today = DateTime(2021, 2, 3); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 5)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2020", - provider: - AlbumMemoryProvider(year: 2020, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2020, month: 2, day: 3, cover: file), ), ], ); @@ -198,8 +184,9 @@ void _prevYear2DaysAfter() { /// File: 2019-02-26 /// Expect: empty void _onFeb29AddFeb26() { + final account = util.buildAccount(); final today = DateTime(2020, 2, 29); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2019, 2, 26)); obj.addFile(file); @@ -212,24 +199,19 @@ void _onFeb29AddFeb26() { /// File: 2019-02-27 /// Expect: [2019] void _onFeb29AddFeb27() { + final account = util.buildAccount(); final today = DateTime(2020, 2, 29); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2019, 2, 27)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2019", - provider: - AlbumMemoryProvider(year: 2019, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2019, month: 2, day: 29, cover: file), ), ], ); @@ -241,8 +223,9 @@ void _onFeb29AddFeb27() { /// File: 2019-03-04 /// Expect: empty void _onFeb29AddMar4() { + final account = util.buildAccount(); final today = DateTime(2020, 2, 29); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2019, 3, 4)); obj.addFile(file); @@ -255,24 +238,19 @@ void _onFeb29AddMar4() { /// File: 2019-03-03 /// Expect: [2019] void _onFeb29AddMar3() { + final account = util.buildAccount(); final today = DateTime(2020, 2, 29); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2019, 3, 3)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2019", - provider: - AlbumMemoryProvider(year: 2019, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2019, month: 2, day: 29, cover: file), ), ], ); @@ -284,8 +262,9 @@ void _onFeb29AddMar3() { /// File: 2016-03-03 /// Expect: empty void _onFeb29AddMar3LeapYear() { + final account = util.buildAccount(); final today = DateTime(2020, 2, 29); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2016, 3, 3)); obj.addFile(file); @@ -298,24 +277,19 @@ void _onFeb29AddMar3LeapYear() { /// File: 2016-03-02 /// Expect: [2016] void _onFeb29AddMar2LeapYear() { + final account = util.buildAccount(); final today = DateTime(2020, 2, 29); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2016, 3, 2)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2016", - provider: - AlbumMemoryProvider(year: 2016, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2016, month: 2, day: 29, cover: file), ), ], ); @@ -327,8 +301,9 @@ void _onFeb29AddMar2LeapYear() { /// File: 2019-12-31 /// Expect: empty void _onJan1AddDec31() { + final account = util.buildAccount(); final today = DateTime(2020, 1, 1); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2019, 12, 31)); obj.addFile(file); @@ -341,24 +316,19 @@ void _onJan1AddDec31() { /// File: 2018-12-31 /// Expect: [2019] void _onJan1AddDec31PrevYear() { + final account = util.buildAccount(); final today = DateTime(2020, 1, 1); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2018, 12, 31)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2019", - provider: - AlbumMemoryProvider(year: 2019, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2019, month: 1, day: 1, cover: file), ), ], ); @@ -370,24 +340,19 @@ void _onJan1AddDec31PrevYear() { /// File: 2020-01-01 /// Expect: [2019] void _onDec31AddJan1() { + final account = util.buildAccount(); final today = DateTime(2020, 12, 31); - final obj = MemoryAlbumHelper(today: today, dayRange: 2); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 2); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2020, 1, 1)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2019", - provider: - AlbumMemoryProvider(year: 2019, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2019, month: 12, day: 31, cover: file), ), ], ); @@ -399,24 +364,19 @@ void _onDec31AddJan1() { /// File: 2021-05-15 /// Expect: [2022] void _onMay15AddMay15Range0() { + final account = util.buildAccount(); final today = DateTime(2022, 5, 15); - final obj = MemoryAlbumHelper(today: today, dayRange: 0); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 0); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2021, 5, 15)); obj.addFile(file); expect( - obj - .build(_nameBuilder) - .map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021)))) - .toList(), + obj.build(_nameBuilder).toList(), [ - Album( + Collection( name: "2021", - provider: - AlbumMemoryProvider(year: 2021, month: today.month, day: today.day), - coverProvider: AlbumMemoryCoverProvider(coverFile: file), - sortProvider: const AlbumTimeSortProvider(isAscending: false), - lastUpdated: DateTime(2021), + contentProvider: CollectionMemoryProvider( + account: account, year: 2021, month: 5, day: 15, cover: file), ), ], ); @@ -428,8 +388,9 @@ void _onMay15AddMay15Range0() { /// File: 2021-05-16 /// Expect: [] void _onMay15AddMay16Range0() { + final account = util.buildAccount(); final today = DateTime(2022, 5, 15); - final obj = MemoryAlbumHelper(today: today, dayRange: 0); + final obj = MemoryCollectionHelper(account, today: today, dayRange: 0); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2021, 5, 16)); obj.addFile(file); @@ -442,8 +403,9 @@ void _onMay15AddMay16Range0() { /// File: 2021-05-16 /// Expect: [] void _onMay15AddMay16RangeNegative() { + final account = util.buildAccount(); final today = DateTime(2022, 5, 15); - final obj = MemoryAlbumHelper(today: today, dayRange: -1); + final obj = MemoryCollectionHelper(account, today: today, dayRange: -1); final file = util.buildJpegFile( path: "", fileId: 0, lastModified: DateTime.utc(2021, 5, 16)); obj.addFile(file); From c4887faf331ebcd3a3ec815d0b5610eb52be45a2 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 29 Apr 2023 01:27:46 +0800 Subject: [PATCH 22/67] Ignore files w/o fileId when working out album cover --- app/lib/entity/album/cover_provider.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index 20a718df..1703ac65 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -80,7 +80,8 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { .map((e) => e.file) .where((element) => file_util.isSupportedFormat(element) && - (element.hasPreview ?? false)) + (element.hasPreview ?? false) && + element.fileId != null) .sorted(compareFileDateTimeDescending) .firstOrNull; } From 815c023aced0d71928718915e1ab49f43e1f5a1d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 29 Apr 2023 11:19:48 +0800 Subject: [PATCH 23/67] Fix compatibility with very old album --- app/lib/entity/album/upgrader.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index 89a204ac..da0d46ae 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -280,13 +280,18 @@ class AlbumUpgraderV8 implements AlbumUpgrader { final content = (result["coverProvider"]["content"]["coverFile"] as Map) .cast(); final fd = _fileJsonToFileDescriptorJson(content); - result["coverProvider"]["content"]["coverFile"] = fd; + // some very old album file may contain files w/o id + if (fd["fdId"] == null) { + result["coverProvider"]["content"]["coverFile"] = fd; + } } else if (result["coverProvider"]["type"] == "auto") { final content = (result["coverProvider"]["content"]["coverFile"] as Map?) ?.cast(); if (content != null) { final fd = _fileJsonToFileDescriptorJson(content); - result["coverProvider"]["content"]["coverFile"] = fd; + if (fd["fdId"] == null) { + result["coverProvider"]["content"]["coverFile"] = fd; + } } } return result; From 9fb8ff6cfc05d4a33cdea704e668238b5502d674 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 29 Apr 2023 11:20:39 +0800 Subject: [PATCH 24/67] Filter dynamic collections from the picker --- app/lib/entity/collection/util.dart | 2 +- app/lib/widget/collection_picker.dart | 3 +++ app/lib/widget/collection_picker/bloc.dart | 15 +++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/lib/entity/collection/util.dart b/app/lib/entity/collection/util.dart index 3370908a..a8b7f07c 100644 --- a/app/lib/entity/collection/util.dart +++ b/app/lib/entity/collection/util.dart @@ -14,7 +14,7 @@ enum CollectionSort { } } -extension CollectionListExtension on List { +extension CollectionListExtension on Iterable { List sortedBy(CollectionSort by) { return map>((e) { switch (by) { diff --git a/app/lib/widget/collection_picker.dart b/app/lib/widget/collection_picker.dart index 98ea9ceb..8f3bb81b 100644 --- a/app/lib/widget/collection_picker.dart +++ b/app/lib/widget/collection_picker.dart @@ -4,6 +4,7 @@ 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/app_localizations.dart'; @@ -11,7 +12,9 @@ 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/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.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; diff --git a/app/lib/widget/collection_picker/bloc.dart b/app/lib/widget/collection_picker/bloc.dart index 9055c2ba..38ef79cc 100644 --- a/app/lib/widget/collection_picker/bloc.dart +++ b/app/lib/widget/collection_picker/bloc.dart @@ -16,7 +16,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { String get tag => _log.fullName; Future _onLoad(_LoadCollections ev, Emitter<_State> emit) async { - _log.info("[_onLoad] $ev"); + _log.info(ev); return emit.forEach( controller.stream, onData: (data) => state.copyWith( @@ -34,24 +34,27 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { } void _onTransformItems(_TransformItems ev, Emitter<_State> emit) { - _log.info("[_onTransformItems] $ev"); + _log.info(ev); final transformed = _transformCollections(ev.collections); emit(state.copyWith(transformedItems: transformed)); } void _onSelectCollection(_SelectCollection ev, Emitter<_State> emit) { - _log.info("[_onTransformItems] $ev"); + _log.info(ev); emit(state.copyWith(result: ev.collection)); } void _onSetError(_SetError ev, Emitter<_State> emit) { - _log.info("[_onSetError] $ev"); + _log.info(ev); emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); } List<_Item> _transformCollections(List collections) { - final sorted = - collections.sortedBy(collection_util.CollectionSort.dateDescending); + final sorted = collections + .where((c) => CollectionAdapter.of( + KiwiContainer().resolve(), account, c) + .isPermitted(CollectionCapability.manualItem)) + .sortedBy(collection_util.CollectionSort.dateDescending); return sorted.map((c) => _Item(c)).toList(); } From aa496245171f83bc5ee6205dc60c6473578d3147 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 29 Apr 2023 12:07:46 +0800 Subject: [PATCH 25/67] Fix compatibility with very old album --- app/lib/entity/album/cover_provider.dart | 2 +- app/lib/entity/album/upgrader.dart | 28 +- app/test/entity/album_test.dart | 538 +-------------- .../entity/album_test/album_upgrader_v8.dart | 624 ++++++++++++++++++ 4 files changed, 649 insertions(+), 543 deletions(-) create mode 100644 app/test/entity/album_test/album_upgrader_v8.dart diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index 1703ac65..d280be3c 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -91,7 +91,7 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { @override FileDescriptor? getCover(Album album) { - if (coverFile == null) { + if (coverFile == null && album.provider is AlbumStaticProvider) { // use the latest file as cover return getCoverByItems(AlbumStaticProvider.of(album).items); } else { diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index da0d46ae..5b03aabe 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -281,16 +281,20 @@ class AlbumUpgraderV8 implements AlbumUpgrader { .cast(); final fd = _fileJsonToFileDescriptorJson(content); // some very old album file may contain files w/o id - if (fd["fdId"] == null) { + if (fd["fdId"] != null) { result["coverProvider"]["content"]["coverFile"] = fd; + } else { + result["coverProvider"]["content"] = {}; } } else if (result["coverProvider"]["type"] == "auto") { final content = (result["coverProvider"]["content"]["coverFile"] as Map?) ?.cast(); if (content != null) { final fd = _fileJsonToFileDescriptorJson(content); - if (fd["fdId"] == null) { + if (fd["fdId"] != null) { result["coverProvider"]["content"]["coverFile"] = fd; + } else { + result["coverProvider"]["content"] = {}; } } } @@ -305,18 +309,26 @@ class AlbumUpgraderV8 implements AlbumUpgrader { .cast(); final converted = _fileJsonToFileDescriptorJson( (content["coverFile"] as Map).cast()); - return dbObj.copyWith( - coverProviderContent: jsonEncode({"coverFile": converted}), - ); + if (converted["fdId"] != null) { + return dbObj.copyWith( + coverProviderContent: jsonEncode({"coverFile": converted}), + ); + } else { + return dbObj.copyWith(coverProviderContent: "{}"); + } } else if (dbObj.coverProviderType == "auto") { final content = (jsonDecode(dbObj.coverProviderContent) as Map) .cast(); if (content["coverFile"] != null) { final converted = _fileJsonToFileDescriptorJson( (content["coverFile"] as Map).cast()); - return dbObj.copyWith( - coverProviderContent: jsonEncode({"coverFile": converted}), - ); + if (converted["fdId"] != null) { + return dbObj.copyWith( + coverProviderContent: jsonEncode({"coverFile": converted}), + ); + } else { + return dbObj.copyWith(coverProviderContent: "{}"); + } } } return dbObj; diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index 50996184..1bf5b0e8 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -17,6 +17,8 @@ import 'package:test/test.dart'; import '../test_util.dart' as util; +part 'album_test/album_upgrader_v8.dart'; + void main() { group("Album", () { group("fromJson", () { @@ -1810,6 +1812,7 @@ void main() { group("auto cover", () { test("null", _upgradeV8JsonAutoNull); test("last modified", _upgradeV8JsonAutoLastModified); + test("no file id", _upgradeV8JsonAutoNoFileId); }); }); @@ -1824,6 +1827,7 @@ void main() { group("auto cover", () { test("null", _upgradeV8DbAutoNull); test("last modified", _upgradeV8DbAutoLastModified); + test("no file id", _upgradeV8DbAutoNoFileId); }); }); }); @@ -1952,540 +1956,6 @@ void _toAppDbJsonShares() { }); } -void _upgradeV8JsonNonManualCover() { - final json = { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "memory", - "content": { - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.678901Z", - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }; - expect(const AlbumUpgraderV8().doJson(json), { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "memory", - "content": { - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.678901Z", - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }); -} - -void _upgradeV8JsonManualNow() { - withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { - final json = { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "manual", - "content": { - "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", - "fileId": 1, - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }; - expect(const AlbumUpgraderV8().doJson(json), { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "manual", - "content": { - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.000Z", - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }); - }); -} - -void _upgradeV8JsonManualExifTime() { - final json = { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "manual", - "content": { - "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", - "fileId": 1, - "metadata": { - "exif": { - "DateTimeOriginal": "2020:01:02 03:04:05", - }, - }, - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }; - expect(const AlbumUpgraderV8().doJson(json), { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "manual", - "content": { - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - // dart does not provide a way to mock timezone - "fdDateTime": DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(), - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }); -} - -void _upgradeV8JsonAutoNull() { - final json = { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "auto", - "content": {}, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }; - expect(const AlbumUpgraderV8().doJson(json), { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "auto", - "content": {}, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }); -} - -void _upgradeV8JsonAutoLastModified() { - final json = { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "auto", - "content": { - "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", - "fileId": 1, - "lastModified": "2020-01-02T03:04:05.000Z", - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }; - expect(const AlbumUpgraderV8().doJson(json), { - "version": 8, - "lastUpdated": "2020-01-02T03:04:05.678901Z", - "provider": { - "type": "static", - "content": { - "items": [], - }, - }, - "coverProvider": { - "type": "auto", - "content": { - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.000Z", - }, - }, - }, - "sortProvider": { - "type": "null", - "content": {}, - }, - "albumFile": { - "path": "remote.php/dav/files/admin/test1.json", - }, - }); -} - -void _upgradeV8DbNonManualCover() { - final dbObj = sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "memory", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.678901Z" - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ); - expect( - const AlbumUpgraderV8().doDb(dbObj), - sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "memory", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.678901Z" - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ), - ); -} - -void _upgradeV8DbManualNow() { - withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { - final dbObj = sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "manual", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", - "fileId": 1 - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ); - expect( - const AlbumUpgraderV8().doDb(dbObj), - sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "manual", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.000Z" - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ), - ); - }); -} - -void _upgradeV8DbManualExifTime() { - final dbObj = sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "manual", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", - "fileId": 1, - "metadata": { - "exif": { - "DateTimeOriginal": "2020:01:02 03:04:05" - } - } - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ); - // dart does not provide a way to mock timezone - final dateTime = DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(); - expect( - const AlbumUpgraderV8().doDb(dbObj), - sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "manual", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "$dateTime" - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ), - ); -} - -void _upgradeV8DbAutoNull() { - final dbObj = sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "auto", - coverProviderContent: "{}", - sortProviderType: "null", - sortProviderContent: "{}", - ); - expect( - const AlbumUpgraderV8().doDb(dbObj), - sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "auto", - coverProviderContent: "{}", - sortProviderType: "null", - sortProviderContent: "{}", - ), - ); -} - -void _upgradeV8DbAutoLastModified() { - final dbObj = sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "auto", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "path": "remote.php/dav/files/admin/test1.jpg", - "fileId": 1, - "lastModified": "2020-01-02T03:04:05.000Z" - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ); - expect( - const AlbumUpgraderV8().doDb(dbObj), - sql.Album( - rowId: 1, - file: 1, - fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", - version: 8, - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), - name: "test1", - providerType: "static", - providerContent: """{"items": []}""", - coverProviderType: "auto", - coverProviderContent: _stripJsonString("""{ - "coverFile": { - "fdPath": "remote.php/dav/files/admin/test1.jpg", - "fdId": 1, - "fdMime": null, - "fdIsArchived": false, - "fdIsFavorite": false, - "fdDateTime": "2020-01-02T03:04:05.000Z" - } - }"""), - sortProviderType: "null", - sortProviderContent: "{}", - ), - ); -} - String _stripJsonString(String str) { return jsonEncode(jsonDecode(str)); } diff --git a/app/test/entity/album_test/album_upgrader_v8.dart b/app/test/entity/album_test/album_upgrader_v8.dart new file mode 100644 index 00000000..6318d5f4 --- /dev/null +++ b/app/test/entity/album_test/album_upgrader_v8.dart @@ -0,0 +1,624 @@ +part of '../album_test.dart'; + +void _upgradeV8JsonNonManualCover() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "memory", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8().doJson(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "memory", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8JsonManualNow() { + withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8().doJson(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); +} + +void _upgradeV8JsonManualExifTime() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "metadata": { + "exif": { + "DateTimeOriginal": "2020:01:02 03:04:05", + }, + }, + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8().doJson(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + // dart does not provide a way to mock timezone + "fdDateTime": DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(), + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8JsonAutoNull() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8().doJson(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8JsonAutoLastModified() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "lastModified": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8().doJson(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8JsonAutoNoFileId() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "lastModified": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8().doJson(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8DbNonManualCover() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "memory", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "memory", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbManualNow() { + withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1 + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); + }); +} + +void _upgradeV8DbManualExifTime() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "metadata": { + "exif": { + "DateTimeOriginal": "2020:01:02 03:04:05" + } + } + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + // dart does not provide a way to mock timezone + final dateTime = DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "manual", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "$dateTime" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbAutoNull() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: "{}", + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: "{}", + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbAutoLastModified() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "lastModified": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} + +void _upgradeV8DbAutoNoFileId() { + final dbObj = sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: _stripJsonString("""{ + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "lastModified": "2020-01-02T03:04:05.000Z" + } + }"""), + sortProviderType: "null", + sortProviderContent: "{}", + ); + expect( + const AlbumUpgraderV8().doDb(dbObj), + sql.Album( + rowId: 1, + file: 1, + fileEtag: "8a3e0799b6f0711c23cc2d93950eceb5", + version: 8, + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + name: "test1", + providerType: "static", + providerContent: """{"items": []}""", + coverProviderType: "auto", + coverProviderContent: "{}", + sortProviderType: "null", + sortProviderContent: "{}", + ), + ); +} From ec8e9efa6f77dc775c5a3955eda1b00ea891df1f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 29 Apr 2023 01:50:54 +0800 Subject: [PATCH 26/67] Tidy up translation file --- app/lib/l10n/app_cs.arb | 94 +--------- app/lib/l10n/app_de.arb | 114 +----------- app/lib/l10n/app_el.arb | 174 +----------------- app/lib/l10n/app_en.arb | 130 ++++--------- app/lib/l10n/app_es.arb | 108 ++--------- app/lib/l10n/app_fi.arb | 102 ++-------- app/lib/l10n/app_fr.arb | 129 ++----------- app/lib/l10n/app_pl.arb | 145 ++------------- app/lib/l10n/app_pt.arb | 100 +--------- app/lib/l10n/app_ru.arb | 167 ++--------------- app/lib/l10n/app_zh.arb | 123 +------------ app/lib/l10n/app_zh_Hant.arb | 123 +------------ app/lib/l10n/untranslated-messages.txt | 132 +++++++++++-- app/lib/web/notification.dart | 2 +- app/lib/widget/album_browser.dart | 2 +- app/lib/widget/album_browser_app_bar.dart | 2 +- app/lib/widget/album_browser_mixin.dart | 8 +- app/lib/widget/changelog.dart | 3 +- .../widget/collection_browser/app_bar.dart | 8 +- app/lib/widget/collection_picker.dart | 2 +- app/lib/widget/export_collection_dialog.dart | 4 +- .../add_selection_to_collection_handler.dart | 2 +- app/lib/widget/home_collections.dart | 2 +- app/lib/widget/home_photos.dart | 2 +- app/lib/widget/home_search.dart | 2 +- app/lib/widget/new_collection_dialog.dart | 6 +- app/lib/widget/viewer_detail_pane.dart | 4 +- 27 files changed, 290 insertions(+), 1400 deletions(-) diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index fdeb8328..2a30d0e8 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Všechny položky úspěšně přidány do alba {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Nepodařilo se přidat položky do alba", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Přidat do alba", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Úspěšně přidáno do alba {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Nepodařilo se přidat do alba", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Mazání 1 položky} other{Mazání {count} položek}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Odebrat vybrané z alba", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 položka odebrána z alba} =2{2 položky odebrány z alba} =3{3 položky odebrány z alba} =4{4 položky odebrány z alba} other{{count} položek odebráno z alba}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Nepodařilo se vytvořit album", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Prázdné} =1{1 položka} =2{2 položky} =3{3 položky} =4{4 položky} other{{count} položek}}", "@albumSize": { "description": "Number of items inside an album", @@ -225,10 +187,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Zadejte název alba", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "PŘESKOČIT", "@skipButtonLabel": { "description": "Label of the skip button" @@ -706,7 +664,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Vyhledávání", - "albumSearchTextFieldHint": "Hledat alba", "clearTooltip": "Vymazat", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -777,18 +734,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Nastavování fotky jako obal alba", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Obal alba úspěšně nastaven", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Nepodařilo se nastavit obal alba", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Zpracovávání metadat obrázků na pozadí", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -869,10 +814,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Nepodařilo se zrušit nastavení obalu", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Ztlumit", "@muteTooltip": { "description": "Mute the video player" @@ -885,15 +826,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 fotka} =2{2 fotky} =3{3 fotky} =4{4 fotky} other{{count} fotek}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Prezentace", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -1039,12 +971,12 @@ "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Přidat do sbírky", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Přidat do sbírky", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Přidávání alba {album} do vaší sbírky", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Přidávání alba {album} do vaší sbírky", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -1052,8 +984,8 @@ } } }, - "addToCollectionSuccessNotification": "Album {album} úspěšně přidáno do vaší sbírky", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "Album {album} úspěšně přidáno do vaší sbírky", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -1195,18 +1127,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Převést na album", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Tento převod je nevratný", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Sbírka úspěšně převedena na album", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Oblíbené", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1541,4 +1461,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index ea6db623..a71c91e2 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Alle Elemente wurden erfolgreich zu {album} hinzugefügt", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Fehler beim Hinzufügen von Elementen zum Album", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Zum Album hinzufügen", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Erfolgreich zu {album} hinzugefügt", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Fehler beim Hinzufügen zum Album", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Lösche 1 Element} other{Lösche {count} Elemente}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Ausgewählte aus Album entfernen", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 Element vom Album entfernt} other{{count} Elemente vom Album entfernt}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Fehler beim Erstellen des Albums", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Leer} =1{1 Element} other{{count} Elemente}}", "@albumSize": { "description": "Number of items inside an album", @@ -217,10 +179,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Bitte geben Sie den Albumnamen ein", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "ÜBERSPRINGEN", "@skipButtonLabel": { "description": "Label of the skip button" @@ -311,20 +269,12 @@ }, "settingsViewerTitle": "Viewer", "settingsViewerDescription": "Den Bild-/Video-Viewer anpassen", - "settingsViewerPageTitle": "Viewer-Einstellungen", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsScreenBrightnessTitle": "Bildschirmhelligkeit", "settingsScreenBrightnessDescription": "Bildschirmhelligkeit des Systems überschreiben", "settingsForceRotationTitle": "Rotationssperre ignorieren", "settingsForceRotationDescription": "Drehen Sie den Bildschirm, auch wenn die automatische Drehung deaktiviert ist", "settingsThemeTitle": "Thema", "settingsThemeDescription": "Passen Sie das Erscheinungsbild der App an", - "settingsThemePageTitle": "Themen Einstellungen", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsFollowSystemThemeTitle": "Systemthema folgen", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -402,10 +352,6 @@ "@connectButtonLabel": { "description": "Label of the connect button" }, - "rootPickerSkipConfirmationDialogContent": "Alle Ihre Dateien werden aufgenommen", - "@rootPickerSkipConfirmationDialogContent": { - "description": "Inform user what happens after skipping root picker" - }, "megapixelCount": "{count}MP", "@megapixelCount": { "description": "Resolution of an image in megapixel", @@ -500,38 +446,6 @@ "@albumDirPickerListEmptyNotification": { "description": "Error when user pressing confirm without picking any folders" }, - "createAlbumDialogBasicLabel": "Einfach", - "@createAlbumDialogBasicLabel": { - "description": "Simple album" - }, - "createAlbumDialogBasicDescription": "Einfaches Album organisiert Fotos unabhängig von der Dateihierarchie auf dem Server", - "@createAlbumDialogBasicDescription": { - "description": "Describe what a simple album is" - }, - "createAlbumDialogFolderBasedLabel": "Ordnerbasiert", - "@createAlbumDialogFolderBasedLabel": { - "description": "Folder based album" - }, - "createAlbumDialogFolderBasedDescription": "Ordnerbasiertes Album spiegelt den Inhalt eines Ordners wieder", - "@createAlbumDialogFolderBasedDescription": { - "description": "Describe what a folder based album is" - }, - "convertBasicAlbumMenuLabel": "In einfaches Album umwandeln", - "@convertBasicAlbumMenuLabel": { - "description": "Convert a non-simple album to a simple one" - }, - "convertBasicAlbumConfirmationDialogTitle": "In einfaches Album umwandeln", - "@convertBasicAlbumConfirmationDialogTitle": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumConfirmationDialogContent": "Der Inhalt dieses Albums wird nicht mehr von der App verwaltet. Sie können Fotos frei hinzufügen oder entfernen.\n\nDiese Konvertierung ist nicht rückgängig zu machen", - "@convertBasicAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumSuccessNotification": "Album erfolgreich umgewandelt", - "@convertBasicAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "importFoldersTooltip": "Ordner importieren", "@importFoldersTooltip": { "description": "Menu entry in the album page to import folders as albums" @@ -605,7 +519,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Suche", - "albumSearchTextFieldHint": "Suche Alben", "clearTooltip": "Leeren", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -676,18 +589,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Foto als Albumcover einstellen", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Albumcover erfolgreich eingestellt", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Fehler beim Einstellen des Albumcovers", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Bildmetadaten im Hintergrund verarbeiten", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -767,10 +668,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Fehler beim Aufheben des Covers", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Stumm", "@muteTooltip": { "description": "Mute the video player" @@ -783,15 +680,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 Foto} other{{count} Fotos}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "errorUnauthenticated": "Nicht authentifizierter Zugriff. Bitte melden Sie sich erneut an, wenn das Problem weiterhin besteht", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" @@ -820,4 +708,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index 1f63a59b..c54bb4d4 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -12,10 +12,6 @@ "@collectionsTooltip": { "description": "Groups of photos, e.g., albums, trash bin, etc" }, - "albumsTabLabel": "Συλλογές", - "@albumsTabLabel": { - "description": "Label of the tab that lists user albums" - }, "zoomTooltip": "Εστίαση", "@zoomTooltip": { "description": "Tooltip of the zoom button" @@ -37,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Επιτυχής προσθήκη όλων των αντικειμένων στη συλλογή {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Αποτυχία προσθήκης αντικειμένων σε συλλογή", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Προσθήκη σε συλλογή", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Επιτυχής προσθήκη στη συλλογή {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Αποτυχία προσθήκης σε συλλογή", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Διαγραφή 1 αντικειμένου} other{Διαγραφή {count} αντικειμένων}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -157,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Αφαίρεση επιλεγμένων από τη συλλογή", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 αντικείμενο αφαιρέθηκε από τη συλλογή} other{{count} αντικείμενα αφαιρέθηκαν από τη συλλογή}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -191,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Αποτυχία δημιουργίας συλλογής", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Empty} =1{1 αντικείμενο} other{{count} αντικείμενα}}", "@albumSize": { "description": "Number of items inside an album", @@ -221,10 +179,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Προσθέστε όνομα συλλογής", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "ΠΑΡΑΒΛΕΨΗ", "@skipButtonLabel": { "description": "Label of the skip button" @@ -318,10 +272,6 @@ "description": "Memory albums contain photos taken in a specific time range in the past" }, "settingsAccountTitle": "Λογαριασμός", - "settingsAccountPageTitle": "Ρυθμίσεις λογαριασμού", - "@settingsAccountPageTitle": { - "description": "Dedicated page for account settings" - }, "settingsIncludedFoldersTitle": "Περιλαμβανόμενοι φάκελοι", "@settingsIncludedFoldersTitle": { "description": "Change the included folders of an account" @@ -348,10 +298,6 @@ }, "settingsViewerTitle": "Προβολέας", "settingsViewerDescription": "Customize the image/video viewer", - "settingsViewerPageTitle": "Ρυθμίσεις προβολέα", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsScreenBrightnessTitle": "Φωτεινότητα οθόνης", "settingsScreenBrightnessDescription": "Παράκαμψη του επιπέδου φωτεινότητας του συστήματος", "settingsForceRotationTitle": "Αγνόηση κλειδώματος περιστροφής οθόνης", @@ -359,25 +305,11 @@ "settingsMapProviderTitle": "Πάροχος χάρτη", "settingsAlbumTitle": "Άλμπουμ", "settingsAlbumDescription": "Επεξεργασία άλμπουμ", - "settingsAlbumPageTitle": "Ρυθμίσεις άλμπουμ", - "@settingsAlbumPageTitle": { - "description": "Dedicated page for album settings" - }, "settingsShowDateInAlbumTitle": "Ομαδοποίηση φωτογραφιών κατά ημερομηνία", "settingsShowDateInAlbumDescription": "Εφαρμογή μόνο όταν το άλμπουμ είναι ταξινομημένο κατά χρόνο", - "settingsPhotoEnhancementTitle": "Βελτίωση φωτογραφιών", - "settingsPhotoEnhancementPageTitle": "Ρυθμίσεις βελτίωσης φωτογραφιών", - "@settingsPhotoEnhancementPageTitle": { - "description": "Dedicated page for photo enhancement settings" - }, - "settingsEnhanceMaxResolutionTitle": "Μέγιστη απόδοση ανάλυσης", "settingsEnhanceMaxResolutionDescription": "Οι φωτογραφίες μεγαλύτερες από την επιλεγμένη ανάλυση θα μειωθούν.\n\nΟι φωτογραφίες υψηλής ανάλυσης απαιτούν πολύ περισσότερη μνήμη και χρόνο για επεξεργασία. Μειώστε αυτή τη ρύθμιση εάν η εφαρμογή διακοπεί κατά τη βελτίωση των φωτογραφιών.", "settingsThemeTitle": "Εμφάνιση", "settingsThemeDescription": "Προσαρμογή της εμφάνισης της εφαρμογής", - "settingsThemePageTitle": "Ρυθμίσεις εμφάνισης", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsFollowSystemThemeTitle": "Χρήση εμφάνισης συστήματος", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -395,14 +327,12 @@ "description": "When black in dark theme is set to false" }, "settingsMiscellaneousTitle": "Διάφορα", - "settingsMiscellaneousPageTitle": "Διάφορες ρυθμίσεις", "settingsPhotosTabSortByNameTitle": "Ταξινόμηση κατά όνομα αρχείου στις Φωτογραφίες", "@settingsPhotosTabSortByNameTitle": { "description": "Sort photos listed in the Photos tab by filename (descending)" }, "settingsExperimentalTitle": "Πειραματικά", "settingsExperimentalDescription": "Χαρακτηριστικά που δεν είναι έτοιμα για καθημερινή χρήση", - "settingsExperimentalPageTitle": "Πειραματικές ρυθμίσεις", "settingsAboutSectionTitle": "Σχετικά", "@settingsAboutSectionTitle": { "description": "Title of the about section in settings widget" @@ -472,10 +402,6 @@ "@connectButtonLabel": { "description": "Label of the connect button" }, - "rootPickerSkipConfirmationDialogContent": "Θα συμπεριληφθούν όλοι οι φάκελοί σας", - "@rootPickerSkipConfirmationDialogContent": { - "description": "Inform user what happens after skipping root picker" - }, "megapixelCount": "{count}MP", "@megapixelCount": { "description": "Resolution of an image in megapixel", @@ -571,34 +497,6 @@ "@albumDirPickerListEmptyNotification": { "description": "Error when user pressing confirm without picking any folders" }, - "createAlbumDialogBasicLabel": "Απλή", - "@createAlbumDialogBasicLabel": { - "description": "Simple album" - }, - "createAlbumDialogBasicDescription": "Η απλή συλλογή οργανώνει φωτογραφίες ανεξάρτητα από την ιεραρχία αρχείων στον διακομιστή", - "@createAlbumDialogBasicDescription": { - "description": "Describe what a simple album is" - }, - "createAlbumDialogFolderBasedLabel": "Με βάση το φάκελο", - "@createAlbumDialogFolderBasedLabel": { - "description": "Folder based album" - }, - "createAlbumDialogFolderBasedDescription": "Η συλλογή με βάση το φάκελο αντικατοπτρίζει το περιεχόμενο ενός φακέλου", - "@createAlbumDialogFolderBasedDescription": { - "description": "Describe what a folder based album is" - }, - "convertBasicAlbumMenuLabel": "Μετατροπή σε απλή συλλογή", - "@convertBasicAlbumMenuLabel": { - "description": "Convert a non-simple album to a simple one" - }, - "convertBasicAlbumConfirmationDialogTitle": "Μετατροπή σε απλή συλλογή", - "@convertBasicAlbumConfirmationDialogTitle": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumConfirmationDialogContent": "Η διαχείριση των περιεχομένων αυτής της συλλογής δε θα γίνεται πλέον από την εφαρμογή. Θα μπορείτε να προσθέτετε ή να αφαιρείτε ελεύθερα φωτογραφίες.\n\nΑυτή η μετατροπή είναι μη αναστρέψιμη", - "@convertBasicAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, "sortOptionFilenameAscendingLabel": "Όνομα αρχείου", "@sortOptionFilenameAscendingLabel": { "description": "Sort by filename, in ascending order" @@ -619,10 +517,6 @@ "@sortOptionManualLabel": { "description": "Sort manually" }, - "convertBasicAlbumSuccessNotification": "Επιτυχής μετατροπή συλλογής", - "@convertBasicAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "importFoldersTooltip": "Εισαγωγή φακέλων", "@importFoldersTooltip": { "description": "Menu entry in the album page to import folders as albums" @@ -712,18 +606,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Ορισμός φωτογραφίας ως εξώφυλλου άλμπουμ", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Επιτυχής ορισμός φωτογραφίας εξωφύλλου", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Αποτυχία ορισμού φωτογραφίας εξωφύλλου", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Επεξεργασία μεταδεδομένων εικόνας στο παρασκήνιο", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -805,10 +687,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Αποτυχία αφαίρεσης εξωφύλλου", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Σίγαση", "@muteTooltip": { "description": "Mute the video player" @@ -821,15 +699,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 φωτογραφία} other{{count} φωτογραφίες}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Παρουσίαση", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -858,11 +727,6 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "Αρχείο", - "@shareMethodFileTitle": { - "description": "Share the actual file" - }, - "shareMethodFileDescription": "Λήψη αρχείου και κοινοποίηση σε άλλες εφαρμογές", "shareMethodPublicLinkTitle": "Δημόσιος σύνδεσμος", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" @@ -959,19 +823,19 @@ } }, "unshareLinkShareDirDialogTitle": "Διαγραφή φακέλου;", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Αυτός ο φάκελος δημιουργήθηκε από την εφαρμογή για κοινή χρήση πολλών αρχείων ως σύνδεσμος. Δεν είναι πλέον κοινόχρηστο με κανένα μέρος, θέλετε να διαγράψετε αυτόν τον φάκελο;", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Προσθήκη στη συλλογή", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Προσθήκη στη συλλογή", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Προσθήκη του άλμπουμ {album} στη συλλογή", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Προσθήκη του άλμπουμ {album} στη συλλογή", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -979,8 +843,8 @@ } } }, - "addToCollectionSuccessNotification": "Το άλμπουμ {album} προστέθηκε στη συλλογή με επιτυχία", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "Το άλμπουμ {album} προστέθηκε στη συλλογή με επιτυχία", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -989,7 +853,7 @@ } }, "shareAlbumDialogTitle": "Κοινοποίηση με χρήστη", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Το άλμπουμ κοινοποιήθηκε στο χρήστη {user}", @@ -1037,7 +901,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "ΕΠΙΔΙΟΡΘΩΣΗ", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "Επιδιόρθωση όλων", @@ -1122,18 +986,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Μετατροπή σε άλμπουμ", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Αυτή η μετατροπή είναι μη αναστρέψιμη", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Η συλλογή μετατράπηκε σε άλμπουμ με επιτυχία", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Αγαπημένα", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1201,10 +1053,6 @@ "@enhanceLowLightParamBrightnessLabel": { "description": "This parameter sets how much brighter the output will be" }, - "collectionEnhancedPhotosLabel": "Βελτιωμένες", - "@collectionEnhancedPhotosLabel": { - "description": "List photos enhanced by the app" - }, "deletePermanentlyLocalConfirmationDialogContent": "Τα επιλεγμένα στοιχεία θα διαγραφούν οριστικά από αυτήν τη συσκευή.\n\nΑυτή η ενέργεια δεν είναι αναστρέψιμη", "@deletePermanentlyLocalConfirmationDialogContent": { "description": "Make sure the user wants to delete the items from the current device" @@ -1226,7 +1074,6 @@ "description": "Transfer the image style from a reference image to a photo" }, "searchTooltip": "Αναζήτηση", - "albumSearchTextFieldHint": "Αναζήτηση συλλογής", "clearTooltip": "Εκκαθάριση", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -1235,7 +1082,6 @@ "@listNoResultsText": { "description": "When there's nothing in a list" }, - "changelogTitle": "Αρχείο αλλαγών", "@changelogTitle": { "description": "Title of the changelog dialog" diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index dc9984b6..f2c68245 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "All items added to {album} successfully", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Failed adding items to album", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Add to album", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Added to {album} successfully", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Failed adding to album", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Deleting 1 item} other{Deleting {count} items}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,13 +123,9 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Remove selected from album", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 item removed from album} other{{count} items removed from album}}", "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", + "description": "(TO BE REMOVED) Inform user that the selected items are removed from an album successfully", "placeholders": { "count": { "example": "1" @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Failed creating album", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Empty} =1{1 item} other{{count} items}}", "@albumSize": { "description": "Number of items inside an album", @@ -202,7 +164,7 @@ }, "albumArchiveLabel": "Archive", "@albumArchiveLabel": { - "description": "Archive" + "description": "A collection containing all archived photos" }, "connectingToServer": "Connecting to\n{server}", "@connectingToServer": { @@ -225,9 +187,9 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Please enter the album name", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" + "nameInputInvalidEmpty": "Name is required", + "@nameInputInvalidEmpty": { + "description": "Shown when a name input is required but value not given. This is intended to be a generic message and does not assume what this 'name' represents" }, "skipButtonLabel": "SKIP", "@skipButtonLabel": { @@ -706,7 +668,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Search", - "albumSearchTextFieldHint": "Search albums", "clearTooltip": "Clear", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -777,18 +738,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Setting photo as album cover", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Set album cover successfully", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Failed setting album cover", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Processing image metadata in background", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -807,7 +756,7 @@ }, "changelogTitle": "Changelog", "@changelogTitle": { - "description": "Title of the changelog dialog" + "description": "Title of the changelog page" }, "serverCertErrorDialogTitle": "Server certificate cannot be trusted", "@serverCertErrorDialogTitle": { @@ -869,10 +818,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Failed unsetting cover", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Mute", "@muteTooltip": { "description": "Mute the video player" @@ -885,15 +830,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 photo} other{{count} photos}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Slideshow", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -1039,22 +975,21 @@ "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Add to collection", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Add to Collections", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Adding {album} to your collection", - "@addToCollectionProcessingNotification": { - "description": "Adding an album to your collection", + "addToCollectionsViewProcessingNotification": "Adding {album} to your Collections", + "@addToCollectionsViewProcessingNotification": { + "description": "Making an album visible in a user's Collections view", "placeholders": { "album": { "example": "Sunday Walk" } } }, - "addToCollectionSuccessNotification": "Added {album} to your collection successfully", - "@addToCollectionSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", + "addToCollectionsViewSuccessNotification": "Added {album} to your collection successfully", + "@addToCollectionsViewSuccessNotification": { "placeholders": { "album": { "example": "Sunday Walk" @@ -1195,18 +1130,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Convert to album", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "This conversion is nonreversible", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Converted collection to album successfully", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Favorites", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1509,6 +1432,33 @@ "@loopTooltip": { "description": "Enable or disable loop in the video player" }, + "createCollectionFailureNotification": "Failed creating collection", + "@createCollectionFailureNotification": { + "description": "Inform user that a collection cannot be created" + }, + "addItemToCollectionTooltip": "Add to collection", + "@addItemToCollectionTooltip": { + "description": "Add one or more items to a collection" + }, + "addItemToCollectionFailureNotification": "Failed adding to collection", + "@addItemToCollectionFailureNotification": { + "description": "Inform user that the item cannot be added to a collection" + }, + "setCollectionCoverFailureNotification": "Failed setting collection cover", + "@setCollectionCoverFailureNotification": { + "description": "Cannot set the opened item as the collection cover" + }, + "exportCollectionTooltip": "Export", + "@exportCollectionTooltip": { + "description": "Export an arbitrary Collection (typical one with generated contents) as a new static Collection" + }, + "exportCollectionDialogTitle": "Export collection", + "createCollectionDialogNextcloudAlbumLabel": "Nextcloud album", + "@createCollectionDialogNextcloudAlbumLabel": { + "description": "Server-side albums that are available in Nextcloud 25+" + }, + "createCollectionDialogNextcloudAlbumDescription": "Server-side album, require Nextcloud 25 or above", + "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index d6696466..267a657a 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Selección añadida a {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "No se pudo añadir selección al álbum", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Añadir a álbum", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Añadido a {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Error al añadir al álbum", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Borrando 1 elemento} other{Borrando {count} elementos}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Quitar selección del álbum", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 elemento quitado del álbum} other{{count} elementos quitados del álbum}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Error al crear álbum", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Empty} =1{1 elemento} other{{count} elementos}}", "@albumSize": { "description": "Number of items inside an album", @@ -225,10 +187,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Introduce un nombre para el álbum", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "SALTAR", "@skipButtonLabel": { "description": "Label of the skip button" @@ -706,7 +664,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Buscar", - "albumSearchTextFieldHint": "Buscar álbumes", "clearTooltip": "Limpiar", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -773,22 +730,10 @@ "@deletePermanentlyConfirmationDialogContent": { "description": "Make sure the user wants to delete the items" }, - "albumSharedLabel": "Compartido", + "albumSharedLabel": "Compartido", "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Estableciendo foto como portada del álbum", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Portada establecida", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Fallo al establecer portada", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Procesando metadatos en segundo plano", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -869,10 +814,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Error al quitar portada", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Silenciar", "@muteTooltip": { "description": "Mute the video player" @@ -885,15 +826,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 foto} other{{count} fotos}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Presentación de diapositivas", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -917,7 +849,7 @@ "slideshowSetupDialogReverseTitle": "Orden inverso", "@slideshowSetupDialogReverseTitle": { "description": "Whether to play the slideshow in reverse order" - }, + }, "linkCopiedNotification": "Enlace copiado", "@linkCopiedNotification": { "description": "Copied the share link to clipboard" @@ -1032,19 +964,19 @@ } }, "unshareLinkShareDirDialogTitle": "¿Borrar carpeta?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Ésta carpeta fue creada por la app para compartir múltiples archivos en un enlace. Ya no se comparte con nadie, ¿quieres borrarla?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Añadir a colección", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Añadir a colección", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Añadiendo {album} a tu colección", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Añadiendo {album} a tu colección", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -1052,8 +984,8 @@ } } }, - "addToCollectionSuccessNotification": "Añadido {album} a tu colección", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "Añadido {album} a tu colección", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -1062,7 +994,7 @@ } }, "shareAlbumDialogTitle": "Compartir con un usuario", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Álbum compartido con {user}", @@ -1110,7 +1042,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "REPARAR", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "Reparar todo", @@ -1195,18 +1127,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Convertir a álbum independiente", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Esta conversión es irreversible", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Collección convertida a álbum", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Favoritos", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1314,7 +1234,7 @@ "@enhanceColorPopTitle": { "description": "Desaturate the background of a photo" }, - "enhanceColorPopDescription": "Reduce la saturación del fondo de tu foto. Obtendrás mejores resultados en retratos", + "enhanceColorPopDescription": "Reduce la saturación del fondo de tu foto. Obtendrás mejores resultados en retratos", "enhanceGenericParamWeightLabel": "Intensidad", "@enhanceGenericParamWeightLabel": { "description": "This generic parameter sets the weight of the applied effect. The effect will be more obvious when the weight is high." @@ -1541,4 +1461,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index cdd0e569..1abe4e3e 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -1,4 +1,4 @@ - { +{ "appTitle": "Photos", "translator": "pHamala", "@translator": { @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Kaikki kohteet lisätty albumiin {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Kohteiden lisääminen albumiin epäonnistui", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Lisää albumiin", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Lisätty albumiini {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Albumiin lisääminen epäonnistui", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Poistetaan 1 kohde} other{Poistetaan {count} kohdetta}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Poista valitut albumista", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 kohde poistettu albumista} other{{count} kohdetta poistettu albumista}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Albumin luonti epäonnistui", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Empty} =1{1 kohde} other{{count} kohdetta}}", "@albumSize": { "description": "Number of items inside an album", @@ -225,10 +187,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Lisää albumin nimi", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "OHITA", "@skipButtonLabel": { "description": "Label of the skip button" @@ -706,7 +664,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Etsi", - "albumSearchTextFieldHint": "Search albums", "clearTooltip": "Clear", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -777,18 +734,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Aseta kuva albumin kansikuvaksi", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Kansikuva asetetttu onnistuneesti", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Kansikuvan asetus epäonistui", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Prosessoidaan kuvien metadataa taustalla", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -869,10 +814,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Albumin kannen poisto epäonnistui", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Mykistä", "@muteTooltip": { "description": "Mute the video player" @@ -885,15 +826,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 kuva} other{{count} kuvat}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Kuvasarja", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -1032,19 +964,19 @@ } }, "unshareLinkShareDirDialogTitle": "Poista kansio?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Sovellus loi tämän kansion jakaakseen useamman tiedoston samalla linkillä. Sitä ei enää jaeta kenenkään kanssa. Haluatko poistaa tämän kansion?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Lisää kokoelmaan", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Lisää kokoelmaan", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Lisätään {album} kokoelmaasi", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Lisätään {album} kokoelmaasi", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -1052,8 +984,8 @@ } } }, - "addToCollectionSuccessNotification": "{album} lisätty kokoelmaasi", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "{album} lisätty kokoelmaasi", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -1062,7 +994,7 @@ } }, "shareAlbumDialogTitle": "Jaa toisen käyttäjän kanssa", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Albumi jaettu käyttäjän {user} kanssa", @@ -1110,7 +1042,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "KORJAA", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "Korjaa kaikki", @@ -1195,18 +1127,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Muuta albumiksi", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Muutosta ei voi perua", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Kokoelman muutos albumiksi onnistui", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Suosikit", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index e691c719..7000337a 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -8,10 +8,6 @@ "@photosTabLabel": { "description": "Label of the tab that lists user photos" }, - "albumsTabLabel": "Albums", - "@albumsTabLabel": { - "description": "Label of the tab that lists user albums" - }, "zoomTooltip": "Zoom", "@zoomTooltip": { "description": "Tooltip of the zoom button" @@ -33,36 +29,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Tous les éléments ont été ajoutés à {album} avec succès", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Échec de l'ajout d'éléments à l'album", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Ajouter à l'album", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Ajouté à {album} avec succès", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Échec de l'ajout à l'album", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Suppression de 1 élément} other{Suppression de {count} éléments}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +119,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Supprimer la sélection de l'album", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 élément supprimé de l'album} other{{count} éléments supprimés de l'album}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +149,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Échec de la création de l'album", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Empty} =1{1 élément} other{{count} éléments}}", "@albumSize": { "description": "Number of items inside an album", @@ -217,10 +175,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Veuillez saisir le nom de l'album", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "IGNORER", "@skipButtonLabel": { "description": "Label of the skip button" @@ -318,10 +272,6 @@ "description": "Memory albums contain photos taken in a specific time range in the past" }, "settingsAccountTitle": "Compte", - "settingsAccountPageTitle": "Paramètres du compte", - "@settingsAccountPageTitle": { - "description": "Dedicated page for account settings" - }, "settingsIncludedFoldersTitle": "Dossiers inclus", "@settingsIncludedFoldersTitle": { "description": "Change the included folders of an account" @@ -348,10 +298,6 @@ }, "settingsViewerTitle": "Viewer", "settingsViewerDescription": "Customize the image/video viewer", - "settingsViewerPageTitle": "Viewer settings", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsScreenBrightnessTitle": "Luminosité écran", "settingsScreenBrightnessDescription": "Remplacer le niveau de luminosité du système", "settingsForceRotationTitle": "Ignorer le verrouillage de la rotation", @@ -359,18 +305,10 @@ "settingsMapProviderTitle": "Fournisseur de carte", "settingsAlbumTitle": "Album", "settingsAlbumDescription": "Personnaliser les albums", - "settingsAlbumPageTitle": "Paramètres des albums", - "@settingsAlbumPageTitle": { - "description": "Dedicated page for album settings" - }, "settingsShowDateInAlbumTitle": "Regrouper les photos par date", "settingsShowDateInAlbumDescription": "Appliquer uniquement lorsque l'album est trié par heure", "settingsThemeTitle": "Thème", "settingsThemeDescription": "Personnalisez l'apparence de l'application", - "settingsThemePageTitle": "Réglage des thèmes", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsFollowSystemThemeTitle": "Suivre le thème du système", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -389,7 +327,6 @@ }, "settingsExperimentalTitle": "Expérimental", "settingsExperimentalDescription": "Des fonctionnalités qui ne sont pas prêtes pour une utilisation quotidienne", - "settingsExperimentalPageTitle": "Paramètres expérimentaux", "settingsAboutSectionTitle": "À propos", "@settingsAboutSectionTitle": { "description": "Title of the about section in settings widget" @@ -627,7 +564,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Chercher", - "albumSearchTextFieldHint": "Rechercher des albums", "clearTooltip": "Nettoyer", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -698,18 +634,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Définir une photo comme couverture d'album", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Couverture changé avec succès", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Échec du changement de couverture", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Traitement des métadonnées de l'image en arrière-plan", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -784,10 +708,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Échec de la suppression de la couverture", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Mettre en sourdine", "@muteTooltip": { "description": "Mute the video player" @@ -800,15 +720,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 photo} other{{count} photos}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Diaporama", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -837,11 +748,6 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "Fichier", - "@shareMethodFileTitle": { - "description": "Share the actual file" - }, - "shareMethodFileDescription": "Téléchargez le fichier et partagez-le avec d'autres applications", "shareMethodPublicLinkTitle": "Lien public", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" @@ -938,19 +844,19 @@ } }, "unshareLinkShareDirDialogTitle": "Supprimer le dossier ?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Ce dossier a été créé par l'application pour partager plusieurs fichiers sous forme de lien. Il n'est désormais plus partagé avec aucun tiers, voulez-vous supprimer ce dossier ?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Ajouter à la collection", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Ajouter à la collection", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Ajout de {album} à votre collection", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Ajout de {album} à votre collection", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -958,8 +864,8 @@ } } }, - "addToCollectionSuccessNotification": "{album} a été ajouté avec succès à votre collection", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "{album} a été ajouté avec succès à votre collection", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -968,7 +874,7 @@ } }, "shareAlbumDialogTitle": "Partager avec un utilisateur", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Album partagé avec {user}", @@ -1016,7 +922,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "RÉPARER", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "Tout réparer", @@ -1101,18 +1007,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Convertir en album", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Cette conversion est irréversible", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "La collection a été convertie en album avec succès", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Favoris", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1165,7 +1059,6 @@ "@metadataTaskPauseLowBatteryNotification": { "description": "Shown when the app has paused reading image metadata due to low battery" }, - "errorUnauthenticated": "Accès non authentifié. Veuillez vous reconnecter si le problème persiste", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" @@ -1198,4 +1091,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index 1d261a28..d66ef9a7 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Wszystkie elementy zostały pomyślnie dodane do {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Nie udało się dodać elementów do albumu", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Dodaj do albumu", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Pomyślnie dodano do {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Nie udało się dodać do albumu", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Usuwanie jednego elementu} other{Usuwanie elementów w ilości: {count}}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Usuń wybrane elementy z albumu", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{Jeden element usunięty z albumu} other{Z albumu usunięto elementy w ilości: {count}}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Nie można utworzyć albumu", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Brak elementów} other{Liczba elementów w albumie: {count}}}", "@albumSize": { "description": "Number of items inside an album", @@ -217,10 +179,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Proszę podać nazwę albumu", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "POMIŃ", "@skipButtonLabel": { "description": "Label of the skip button" @@ -318,10 +276,6 @@ "description": "Memory albums contain photos taken in a specific time range in the past" }, "settingsAccountTitle": "Konto", - "settingsAccountPageTitle": "Ustawienia konta", - "@settingsAccountPageTitle": { - "description": "Dedicated page for account settings" - }, "settingsIncludedFoldersTitle": "Uwzględniane foldery", "@settingsIncludedFoldersTitle": { "description": "Change the included folders of an account" @@ -348,10 +302,6 @@ }, "settingsViewerTitle": "Przeglądarka", "settingsViewerDescription": "Dostosowywanie przeglądarki obrazów/wideo", - "settingsViewerPageTitle": "Ustawienia przeglądarki", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsScreenBrightnessTitle": "Jasność ekranu", "settingsScreenBrightnessDescription": "Zastąp systemowe ustawienia jasności", "settingsForceRotationTitle": "Zignoruj systemową blokadę orientacji ekranu", @@ -359,18 +309,10 @@ "settingsMapProviderTitle": "Dostawca map", "settingsAlbumTitle": "Album", "settingsAlbumDescription": "Dostosuj albumy", - "settingsAlbumPageTitle": "Ustawienia albumu", - "@settingsAlbumPageTitle": { - "description": "Dedicated page for album settings" - }, "settingsShowDateInAlbumTitle": "Grupuj zdjęcia według daty", "settingsShowDateInAlbumDescription": "Zastosuj tylko wtedy, gdy album jest posortowany według czasu", "settingsThemeTitle": "Motyw", "settingsThemeDescription": "Dostosuj wygląd aplikacji", - "settingsThemePageTitle": "Ustawienia motywu", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsFollowSystemThemeTitle": "Zostosuj motyw systemu", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -389,7 +331,6 @@ }, "settingsExperimentalTitle": "Eksperymentalne", "settingsExperimentalDescription": "Funkcje, które nie są gotowe do codziennego użytku", - "settingsExperimentalPageTitle": "Ustawienia funkcji eksperymentalnych", "settingsAboutSectionTitle": "O aplikacji", "@settingsAboutSectionTitle": { "description": "Title of the about section in settings widget" @@ -550,38 +491,6 @@ "@albumDirPickerListEmptyNotification": { "description": "Error when user pressing confirm without picking any folders" }, - "createAlbumDialogBasicLabel": "Prosty", - "@createAlbumDialogBasicLabel": { - "description": "Simple album" - }, - "createAlbumDialogBasicDescription": "Prosty album organizuje zdjęcia niezależnie od hierarchii plików na serwerze", - "@createAlbumDialogBasicDescription": { - "description": "Describe what a simple album is" - }, - "createAlbumDialogFolderBasedLabel": "Folder", - "@createAlbumDialogFolderBasedLabel": { - "description": "Folder based album" - }, - "createAlbumDialogFolderBasedDescription": "Album oparty na folderach odzwierciedla zawartość folderu", - "@createAlbumDialogFolderBasedDescription": { - "description": "Describe what a folder based album is" - }, - "convertBasicAlbumMenuLabel": "Konwersja do prostego albumu", - "@convertBasicAlbumMenuLabel": { - "description": "Convert a non-simple album to a simple one" - }, - "convertBasicAlbumConfirmationDialogTitle": "Konwersja do prostego albumu", - "@convertBasicAlbumConfirmationDialogTitle": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumConfirmationDialogContent": "Zawartość tego albumu nie będzie już zarządzana przez aplikację. Będziesz mógł swobodnie dodawać i usuwać zdjęcia.\n\nTa konwersja jest nieodwracalna", - "@convertBasicAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumSuccessNotification": "Album skonwertowany pomyślnie", - "@convertBasicAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "importFoldersTooltip": "Importuj foldery", "@importFoldersTooltip": { "description": "Menu entry in the album page to import folders as albums" @@ -659,7 +568,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Szukaj", - "albumSearchTextFieldHint": "Szukaj albumów", "clearTooltip": "Czyść", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -730,18 +638,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Ustawianie zdjęcia jako okładki albumu", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Okładka albumu ustawona pomyślnie", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Nie udało się ustawić okładki albumu", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Przetwarzanie metadanych obrazu w tle", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -822,10 +718,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Nie udało się odznaczyć okładki", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Wycisz", "@muteTooltip": { "description": "Mute the video player" @@ -838,15 +730,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{Jedno zdjęcie} other{Liczba zdjęć: {count}}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Pokaz zdjęć", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -875,11 +758,6 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "Plik", - "@shareMethodFileTitle": { - "description": "Share the actual file" - }, - "shareMethodFileDescription": "Plik zostanie pobrany na to urządzenie i udostępniony (przesłany) za pomocą innej aplikacji", "shareMethodPublicLinkTitle": "Publiczny link", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" @@ -976,19 +854,19 @@ } }, "unshareLinkShareDirDialogTitle": "Usunąć folder?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Ten folder został utworzony przez aplikację do udostępniania wielu plików jako link. Teraz nie jest on już udostępniany, czy chcesz usunąć ten folder?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Dodaj do kolekcji", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Dodaj do kolekcji", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Dodawanie {album} do kolekcji", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Dodawanie {album} do kolekcji", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -996,8 +874,8 @@ } } }, - "addToCollectionSuccessNotification": "Pomyślnie dodano album {album} do kolekcji", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "Pomyślnie dodano album {album} do kolekcji", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -1006,7 +884,7 @@ } }, "shareAlbumDialogTitle": "Udostępnij użytkownikowi", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Album współdzielony z {user}", @@ -1054,7 +932,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "NAPRAW", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "Napraw wszystko", @@ -1119,7 +997,6 @@ "@homeFolderInputInvalidEmpty": { "description": "Home folder can't be left empty" }, - "errorUnauthenticated": "Nieautoryzowany dostęp. Jeśli problem będzie się powstarzał zaloguj się ponownie", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" @@ -1152,4 +1029,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index 3ad588fb..50f3d427 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Todos os itens foram adicionados ao álbum {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "Falha ao adicionar itens ao álbum", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Adiconar ao álbum", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Adicionado a {album} com sucesso", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "Falha ao adicionar ao álbum", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Apagando 1 item} other{Apagando {count} itens}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Remove os selecionados do álbum", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 item foi removido do álbum} other{{count} itens foram removidos do álbum}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Falha ao criar álbum", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Vazio} =1{1 item} other{{count} itens}}", "@albumSize": { "description": "Number of items inside an album", @@ -225,10 +187,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Insira o nome do álbum", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "Pular", "@skipButtonLabel": { "description": "Label of the skip button" @@ -706,7 +664,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Busca", - "albumSearchTextFieldHint": "Busque álbuns", "clearTooltip": "Limpar", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -777,18 +734,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Configurando foto de capa de álbum", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Capa do álbum modificada com sucesso", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Falha ao configurar capa do álbum", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Processando metadados das imagens em segundo plano", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -869,10 +814,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Falhou em desmarcar como capa", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Sem áudio", "@muteTooltip": { "description": "Mute the video player" @@ -885,15 +826,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 foto} other{{count} fotos}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Slideshow", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -1032,19 +964,19 @@ } }, "unshareLinkShareDirDialogTitle": "Apagar pasta?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Essa pasta foi criada automaticamente pelo app para compartilhar vários arquivos ao mesmo tempo através de um link. No momento ela não é mais compartilhada com ninguém. Quer apagar esta pasta?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Adicionar à minha coleção", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Adicionar à minha coleção", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Adicionando {album} para minha coleção", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Adicionando {album} para minha coleção", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -1052,8 +984,8 @@ } } }, - "addToCollectionSuccessNotification": "{album} adicionado à coleção com sucesso", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "{album} adicionado à coleção com sucesso", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -1062,7 +994,7 @@ } }, "shareAlbumDialogTitle": "Compartilhar com usuário", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Álbum compartilhado com {user}", @@ -1195,18 +1127,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Converter para álbum", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Essa conversão é irreversível", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Converteu a coleção em álbum com sucesso", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Favoritas", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1541,4 +1461,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index 0405e316..ce788df0 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "Выбранные фото добавлены в альбом {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Воскресная прогулка" - } - } - }, - "addSelectedToAlbumFailureNotification": "Не удалось добавить фото в альбом", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "Добавить в альбом", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "Добавлено в альбом {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Воскресная прогулка" - } - } - }, - "addToAlbumFailureNotification": "Не удалось добавить в альбом", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, =1{Удаление фото} other{Удаление {count} фото}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "Убрать выбранное из альбома", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 фото убрано из альбома} other{{count} фото убрано из альбома}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "Не удалось создать альбом", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, =0{Пустой} =1{1 фото} other{{count} фото}}", "@albumSize": { "description": "Number of items inside an album", @@ -217,10 +179,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "Пожалуйста, укажите название альбома", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "ПРОПУСТИТЬ", "@skipButtonLabel": { "description": "Label of the skip button" @@ -309,20 +267,12 @@ "@settingsExifSupportTrueSubtitle": { "description": "Subtitle of the EXIF support setting when the value is true. The goal is to warn user about the possible side effects of enabling this setting" }, - "settingsViewerTitle": "Просмотр", + "settingsViewerTitle": "Просмотр", "settingsViewerDescription": "Настройки просмотра фото/видео", - "settingsViewerPageTitle": "Настройки просмотра", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsShowDateInAlbumTitle": "Группировать фото по дате", "settingsShowDateInAlbumDescription": "Применяется только в том случае, если альбом отсортирован по времени", "settingsThemeTitle": "Оформление", "settingsThemeDescription": "Настройки внешнего вида приложения", - "settingsThemePageTitle": "Настройки оформления", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsScreenBrightnessTitle": "Яркость экрана", "settingsScreenBrightnessDescription": "Настройка яркости экрана независимо от системных настроек", "settingsForceRotationTitle": "Игнорировать блокировку поворота экрана", @@ -330,10 +280,6 @@ "settingsMapProviderTitle": "Постащик карт", "settingsAlbumTitle": "Альбомы", "settingsAlbumDescription": "Настройки альбомов", - "settingsAlbumPageTitle": "Настройки альбомов", - "@settingsAlbumPageTitle": { - "description": "Dedicated page for album settings" - }, "settingsFollowSystemThemeTitle": "Использовать системные настройки", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -395,10 +341,6 @@ "@connectButtonLabel": { "description": "Label of the connect button" }, - "rootPickerSkipConfirmationDialogContent": "Будут выбраны все файлы", - "@rootPickerSkipConfirmationDialogContent": { - "description": "Inform user what happens after skipping root picker" - }, "megapixelCount": "{count} Мп", "@megapixelCount": { "description": "Resolution of an image in megapixel", @@ -494,38 +436,6 @@ "@albumDirPickerListEmptyNotification": { "description": "Error when user pressing confirm without picking any folders" }, - "createAlbumDialogBasicLabel": "Обычный", - "@createAlbumDialogBasicLabel": { - "description": "Simple album" - }, - "createAlbumDialogBasicDescription": "Обычный альбом позволяет организовать фотографии независимо от структуры папок на сервере", - "@createAlbumDialogBasicDescription": { - "description": "Describe what a simple album is" - }, - "createAlbumDialogFolderBasedLabel": "Основанный на папках", - "@createAlbumDialogFolderBasedLabel": { - "description": "Folder based album" - }, - "createAlbumDialogFolderBasedDescription": "Основанный на папках альбом показывает содержимое добавленных в него папок", - "@createAlbumDialogFolderBasedDescription": { - "description": "Describe what a folder based album is" - }, - "convertBasicAlbumMenuLabel": "Преобразовать в обычный альбом", - "@convertBasicAlbumMenuLabel": { - "description": "Convert a non-simple album to a simple one" - }, - "convertBasicAlbumConfirmationDialogTitle": "Преобразование в обычный альбом", - "@convertBasicAlbumConfirmationDialogTitle": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumConfirmationDialogContent": "Приложение больше не будет управлять содержимым этого альбома. Вы сможете свободно добавлять и удалять фотографии.\n\nЭто преобразование необратимо", - "@convertBasicAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertBasicAlbumSuccessNotification": "Альбом преобразован", - "@convertBasicAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "importFoldersTooltip": "Импорт папки", "@importFoldersTooltip": { "description": "Menu entry in the album page to import folders as albums" @@ -591,7 +501,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "Поиск", - "albumSearchTextFieldHint": "Поиск альбомов", "clearTooltip": "Очистить", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -662,18 +571,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "Изменение обложки альбома", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "Обложка альбома изменена", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "Не удалось изменить обложку альбома", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "Обрабатываются метаданные изображений", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -686,7 +583,7 @@ "useAsAlbumCoverTooltip": "Использовать как обложку альбома", "helpTooltip": "Справка", "helpButtonLabel": "СПРАВКА", - "removeFromAlbumTooltip": "Убрать из альбома", + "removeFromAlbumTooltip": "Убрать из альбома", "@removeFromAlbumTooltip": { "description": "Remove the opened photo from an album" }, @@ -726,7 +623,7 @@ "@whitelistCertButtonLabel": { "description": "Label of the whitelist certificate button" }, - "fileSharedByDescription": "Поделился пользователь", + "fileSharedByDescription": "Поделился пользователь", "@fileSharedByDescription": { "description": "The app will show the owner of the file if it's being shared with you by others. The name of the owner is displayed above this line" }, @@ -754,13 +651,8 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "Не удалось сбросить обложку альбома", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "Убрать звук", "unmuteTooltip": "Включить звук", - "errorUnauthenticated": "Неавторизованный доступ. Если ошибка возникает снова, попробуйте перелогиниться", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" @@ -794,10 +686,6 @@ "description": "Memory albums contain photos taken in a specific time range in the past" }, "settingsAccountTitle": "Учётная запись", - "settingsAccountPageTitle": "Настройки учётной записи", - "@settingsAccountPageTitle": { - "description": "Dedicated page for account settings" - }, "settingsIncludedFoldersTitle": "Выбранные папки", "@settingsIncludedFoldersTitle": { "description": "Change the included folders of an account" @@ -824,7 +712,6 @@ }, "settingsExperimentalTitle": "Экспериментальные", "settingsExperimentalDescription": "Функции, которые не готовы к повседневному использованию", - "settingsExperimentalPageTitle": "Экспериментальные настройки", "settingsAboutSectionTitle": "О программе", "@settingsAboutSectionTitle": { "description": "Title of the about section in settings widget" @@ -861,15 +748,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, =1{1 фото} other{{count} фото}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "Слайд-шоу", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -898,11 +776,6 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "Файл", - "@shareMethodFileTitle": { - "description": "Share the actual file" - }, - "shareMethodFileDescription": "Скачайте файл и поделитесь им с другими приложениями", "shareMethodPublicLinkTitle": "Общедоступная ссылка", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" @@ -999,19 +872,19 @@ } }, "unshareLinkShareDirDialogTitle": "Удалить папку?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "Эта папка была создана предоставления общего доступа к нескольким файлами в виде ссылки. Теперь она больше не используется для общего доступа с другими пользователями, хотите удалить эту папку?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "Добавить в коллекцию", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "Добавить в коллекцию", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "Добавление {album} в коллекцию", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "Добавление {album} в коллекцию", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -1019,8 +892,8 @@ } } }, - "addToCollectionSuccessNotification": "{album} успешно добавлен в коллекцию", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "{album} успешно добавлен в коллекцию", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -1029,7 +902,7 @@ } }, "shareAlbumDialogTitle": "Предоставить общий доступ пользователю", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "Ползователю {user} предоставлен общий доступ к альбому", @@ -1077,7 +950,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "ИСПРАВИТЬ", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "Исправить всё", @@ -1162,18 +1035,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "Конвертировать в альбом", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "Это изменение нельзя отменить", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "Коллекция успешно преобразована в альбом", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "Избранное", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1234,4 +1095,4 @@ "@errorNoStoragePermission": { "description": "Missing permission on Android" } -} +} \ No newline at end of file diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 63e53c6e..1c57b409 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "成功添加已选项目到 {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "未能添加项目到相册", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "添加到相册", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "成功添加到 {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "未能添加到相册", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, other{正在删除 {count} 个项目}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "从相册中移除已选项目", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, other{成功从相册中移除 {count} 个项目}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "未能新建相册", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, other{{count} 项内容}}", "@albumSize": { "description": "Number of items inside an album", @@ -217,10 +179,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "请输入相册名称", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "跳过", "@skipButtonLabel": { "description": "Label of the skip button" @@ -318,10 +276,6 @@ "description": "Memory albums contain photos taken in a specific time range in the past" }, "settingsAccountTitle": "帐号", - "settingsAccountPageTitle": "帐号设置", - "@settingsAccountPageTitle": { - "description": "Dedicated page for account settings" - }, "settingsIncludedFoldersTitle": "已选取的文件夹", "@settingsIncludedFoldersTitle": { "description": "Change the included folders of an account" @@ -348,10 +302,6 @@ }, "settingsViewerTitle": "查看器", "settingsViewerDescription": "设置照片和视频查看器", - "settingsViewerPageTitle": "查看器设置", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsScreenBrightnessTitle": "屏幕亮度", "settingsScreenBrightnessDescription": "覆盖系统亮度设置", "settingsForceRotationTitle": "无视屏幕旋转锁定", @@ -359,18 +309,10 @@ "settingsMapProviderTitle": "地图供应商", "settingsAlbumTitle": "相册", "settingsAlbumDescription": "设置相册", - "settingsAlbumPageTitle": "相册设置", - "@settingsAlbumPageTitle": { - "description": "Dedicated page for album settings" - }, "settingsShowDateInAlbumTitle": "显示日期分类", "settingsShowDateInAlbumDescription": "只应用于以日期排序的相册", "settingsThemeTitle": "主题", "settingsThemeDescription": "设置 App 的外观", - "settingsThemePageTitle": "主题设置", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsFollowSystemThemeTitle": "跟随系统主题", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -389,7 +331,6 @@ }, "settingsExperimentalTitle": "实验", "settingsExperimentalDescription": "可能不稳定的实验性功能", - "settingsExperimentalPageTitle": "实验设置", "settingsAboutSectionTitle": "关于", "@settingsAboutSectionTitle": { "description": "Title of the about section in settings widget" @@ -627,7 +568,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "搜寻", - "albumSearchTextFieldHint": "搜寻相册", "clearTooltip": "清除", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -698,18 +638,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "正在设置相册封面", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "成功设置相册封面", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "未能设置相册封面", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "正在背景读取照片的中继数据", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -790,10 +718,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "未能取消设为相册封面", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "静音", "@muteTooltip": { "description": "Mute the video player" @@ -806,15 +730,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, other{{count} 张照片}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "幻灯片", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -843,11 +758,6 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "文件", - "@shareMethodFileTitle": { - "description": "Share the actual file" - }, - "shareMethodFileDescription": "下载文件并分享到其他 App", "shareMethodPublicLinkTitle": "公开链接", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" @@ -944,19 +854,19 @@ } }, "unshareLinkShareDirDialogTitle": "删除文件夹?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "此文件夹本是 App 为了分享多文件而建立的,而此文件夹现在已没有任何分享,你希望删除此文件夹吗?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "添加到收藏库", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "添加到收藏库", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "正在添加 {album} 到收藏库", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "正在添加 {album} 到收藏库", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -964,8 +874,8 @@ } } }, - "addToCollectionSuccessNotification": "成功添加 {album} 到收藏库", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "成功添加 {album} 到收藏库", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -974,7 +884,7 @@ } }, "shareAlbumDialogTitle": "分享给用户", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "成功分享相册给 {user}", @@ -1022,7 +932,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "修复", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "修复全部", @@ -1107,18 +1017,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "转换为相册", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "此转换为不可逆操作", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "成功转换照片集至相册", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "收藏", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1171,7 +1069,6 @@ "@metadataTaskPauseLowBatteryNotification": { "description": "Shown when the app has paused reading image metadata due to low battery" }, - "errorUnauthenticated": "未授权的存取,若问题持续请重新登录", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" diff --git a/app/lib/l10n/app_zh_Hant.arb b/app/lib/l10n/app_zh_Hant.arb index df40ee4a..a4923bb3 100644 --- a/app/lib/l10n/app_zh_Hant.arb +++ b/app/lib/l10n/app_zh_Hant.arb @@ -33,36 +33,6 @@ } } }, - "addSelectedToAlbumSuccessNotification": "成功新增已選項目至 {album}", - "@addSelectedToAlbumSuccessNotification": { - "description": "Inform user that the selected items are added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addSelectedToAlbumFailureNotification": "未能新增項目至相簿", - "@addSelectedToAlbumFailureNotification": { - "description": "Inform user that the selected items cannot be added to an album" - }, - "addToAlbumTooltip": "新增至相簿", - "@addToAlbumTooltip": { - "description": "Add selected items to an album" - }, - "addToAlbumSuccessNotification": "成功新增至 {album}", - "@addToAlbumSuccessNotification": { - "description": "Inform user that the item is added to an album successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToAlbumFailureNotification": "未能新增至相簿", - "@addToAlbumFailureNotification": { - "description": "Inform user that the item cannot be added to an album" - }, "deleteSelectedProcessingNotification": "{count, plural, other{正在刪除 {count} 個項目}}", "@deleteSelectedProcessingNotification": { "description": "Inform user that the selected items are being deleted", @@ -153,10 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumTooltip": "從相簿中移除已選項目", - "@removeSelectedFromAlbumTooltip": { - "description": "Tooltip of the button that remove selected items from an album" - }, "removeSelectedFromAlbumSuccessNotification": "{count, plural, other{成功從相簿中移除 {count} 個項目}}", "@removeSelectedFromAlbumSuccessNotification": { "description": "Inform user that the selected items are removed from an album successfully", @@ -187,10 +153,6 @@ "@createAlbumTooltip": { "description": "Tooltip of the button that creates a new album" }, - "createAlbumFailureNotification": "未能新增相簿", - "@createAlbumFailureNotification": { - "description": "Inform user that an album cannot be created" - }, "albumSize": "{count, plural, other{{count} 個項目}}", "@albumSize": { "description": "Number of items inside an album", @@ -217,10 +179,6 @@ "@nameInputHint": { "description": "Hint of the text field expecting name data" }, - "albumNameInputInvalidEmpty": "請輸入相簿名稱", - "@albumNameInputInvalidEmpty": { - "description": "Inform user that the album name input field cannot be empty" - }, "skipButtonLabel": "跳過", "@skipButtonLabel": { "description": "Label of the skip button" @@ -318,10 +276,6 @@ "description": "Memory albums contain photos taken in a specific time range in the past" }, "settingsAccountTitle": "帳戶", - "settingsAccountPageTitle": "帳戶設定", - "@settingsAccountPageTitle": { - "description": "Dedicated page for account settings" - }, "settingsIncludedFoldersTitle": "已選取的資料夾", "@settingsIncludedFoldersTitle": { "description": "Change the included folders of an account" @@ -348,10 +302,6 @@ }, "settingsViewerTitle": "檢視器", "settingsViewerDescription": "設置相片和影片檢視器", - "settingsViewerPageTitle": "檢視器設定", - "@settingsViewerPageTitle": { - "description": "Dedicated page for viewer settings" - }, "settingsScreenBrightnessTitle": "螢幕亮度", "settingsScreenBrightnessDescription": "覆蓋系統亮度設定", "settingsForceRotationTitle": "無視螢幕旋轉鎖定", @@ -359,18 +309,10 @@ "settingsMapProviderTitle": "地圖供應商", "settingsAlbumTitle": "相簿", "settingsAlbumDescription": "設置相簿", - "settingsAlbumPageTitle": "相簿設定", - "@settingsAlbumPageTitle": { - "description": "Dedicated page for album settings" - }, "settingsShowDateInAlbumTitle": "顯示日期分類", "settingsShowDateInAlbumDescription": "只應用於以日期排序的相簿", "settingsThemeTitle": "主題", "settingsThemeDescription": "設置 App 的外觀", - "settingsThemePageTitle": "主題設定", - "@settingsThemePageTitle": { - "description": "Dedicated page for theme settings" - }, "settingsFollowSystemThemeTitle": "跟隨系統主題", "@settingsFollowSystemThemeTitle": { "description": "Respect the system dark mode settings introduced on Android 10" @@ -389,7 +331,6 @@ }, "settingsExperimentalTitle": "實驗", "settingsExperimentalDescription": "可能不穩定的實驗性功能", - "settingsExperimentalPageTitle": "實驗設定", "settingsAboutSectionTitle": "關於", "@settingsAboutSectionTitle": { "description": "Title of the about section in settings widget" @@ -627,7 +568,6 @@ "description": "Downloading photos to be shared" }, "searchTooltip": "搜尋", - "albumSearchTextFieldHint": "搜尋相簿", "clearTooltip": "清除", "@clearTooltip": { "description": "Clear some sort of user input, typically a text field" @@ -698,18 +638,6 @@ "@albumSharedLabel": { "description": "A small label placed next to a shared album" }, - "setAlbumCoverProcessingNotification": "正在設置相簿封面", - "@setAlbumCoverProcessingNotification": { - "description": "Setting the opened item as the album cover" - }, - "setAlbumCoverSuccessNotification": "成功設置相簿封面", - "@setAlbumCoverSuccessNotification": { - "description": "Set the opened item as the album cover successfully" - }, - "setAlbumCoverFailureNotification": "未能設置相簿封面", - "@setAlbumCoverFailureNotification": { - "description": "Cannot set the opened item as the album cover" - }, "metadataTaskProcessingNotification": "正在背景讀取相片的中繼資料", "@metadataTaskProcessingNotification": { "description": "Shown when the app is reading image metadata" @@ -790,10 +718,6 @@ "@unsetAlbumCoverSuccessNotification": { "description": "Unset the cover of the opened album successfully" }, - "unsetAlbumCoverFailureNotification": "未能取消設為相簿封面", - "@unsetAlbumCoverFailureNotification": { - "description": "Cannot unset the cover of the opened album" - }, "muteTooltip": "靜音", "@muteTooltip": { "description": "Mute the video player" @@ -806,15 +730,6 @@ "@collectionPeopleLabel": { "description": "Browse photos grouped by person" }, - "personPhotoCountText": "{count, plural, other{{count} 張相片}}", - "@personPhotoCountText": { - "description": "Number of photos associated to a specific person", - "placeholders": { - "count": { - "example": "1" - } - } - }, "slideshowTooltip": "幻燈片", "@slideshowTooltip": { "description": "A button to start a slideshow from the current collection" @@ -843,11 +758,6 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "檔案", - "@shareMethodFileTitle": { - "description": "Share the actual file" - }, - "shareMethodFileDescription": "下載檔案並分享至其他 App", "shareMethodPublicLinkTitle": "公開連結", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" @@ -944,19 +854,19 @@ } }, "unshareLinkShareDirDialogTitle": "刪除資料夾?", - "@unshareLinkShareDirDialogTitle": { + "@unshareLinkShareDirDialogTitle": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, "unshareLinkShareDirDialogContent": "此資料夾本是 App 為了分享多檔案而建立的,而此資料夾現在已沒有任何分享,你希望刪除此資料夾嗎?", - "@unshareLinkShareDirDialogContent": { + "@unshareLinkShareDirDialogContent": { "description": "Dialog shown after user unshared a dir originally created by the app to share multiple files" }, - "addToCollectionTooltip": "新增至收藏庫", - "@addToCollectionTooltip": { + "addToCollectionsViewTooltip": "新增至收藏庫", + "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionProcessingNotification": "正在新增 {album} 至收藏庫", - "@addToCollectionProcessingNotification": { + "addToCollectionsViewProcessingNotification": "正在新增 {album} 至收藏庫", + "@addToCollectionsViewProcessingNotification": { "description": "Adding an album to your collection", "placeholders": { "album": { @@ -964,8 +874,8 @@ } } }, - "addToCollectionSuccessNotification": "成功新增 {album} 至收藏庫", - "@addToCollectionSuccessNotification": { + "addToCollectionsViewSuccessNotification": "成功新增 {album} 至收藏庫", + "@addToCollectionsViewSuccessNotification": { "description": "Inform user that the selected items are deleted successfully", "placeholders": { "album": { @@ -974,7 +884,7 @@ } }, "shareAlbumDialogTitle": "分享給用戶", - "@shareAlbumDialogTitle" : { + "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, "shareAlbumSuccessNotification": "成功分享相簿給 {user}", @@ -1022,7 +932,7 @@ "description": "Fix an issue" }, "fixButtonLabel": "修正", - "@fixButtonLabel":{ + "@fixButtonLabel": { "description": "Fix an issue" }, "fixAllTooltip": "修正全部", @@ -1107,18 +1017,6 @@ "@createCollectionDialogFolderDescription": { "description": "Describe how a folder collection works" }, - "convertAlbumTooltip": "轉換為相簿", - "@convertAlbumTooltip": { - "description": "Convert a non-album collection to an album" - }, - "convertAlbumConfirmationDialogContent": "此轉換為不可逆操作", - "@convertAlbumConfirmationDialogContent": { - "description": "Make sure the user wants to perform the conversion" - }, - "convertAlbumSuccessNotification": "成功轉換相片集至相簿", - "@convertAlbumSuccessNotification": { - "description": "Successfully converted the album" - }, "collectionFavoritesLabel": "我的最愛", "@collectionFavoritesLabel": { "description": "Browse photos added to favorites" @@ -1171,7 +1069,6 @@ "@metadataTaskPauseLowBatteryNotification": { "description": "Shown when the app has paused reading image metadata due to low battery" }, - "errorUnauthenticated": "未授權的存取,若問題持續請重新登入", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 87cc7ee7..3f0ec808 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -1,7 +1,20 @@ { + "cs": [ + "nameInputInvalidEmpty", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" + ], + "de": [ "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -83,9 +96,9 @@ "copyItemsFailureNotification", "unshareLinkShareDirDialogTitle", "unshareLinkShareDirDialogContent", - "addToCollectionTooltip", - "addToCollectionProcessingNotification", - "addToCollectionSuccessNotification", + "addToCollectionsViewTooltip", + "addToCollectionsViewProcessingNotification", + "addToCollectionsViewSuccessNotification", "shareAlbumDialogTitle", "shareAlbumSuccessNotification", "shareAlbumSuccessWithErrorNotification", @@ -113,9 +126,6 @@ "createCollectionDialogAlbumDescription", "createCollectionDialogFolderLabel", "createCollectionDialogFolderDescription", - "convertAlbumTooltip", - "convertAlbumConfirmationDialogContent", - "convertAlbumSuccessNotification", "collectionFavoritesLabel", "favoriteTooltip", "favoriteSuccessNotification", @@ -199,12 +209,21 @@ "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription", "errorAlbumDowngrade" ], "el": [ "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -292,17 +311,45 @@ "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", - "loopTooltip" + "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" + ], + + "es": [ + "nameInputInvalidEmpty", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ], "fi": [ - + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ], "fr": [ "collectionsTooltip", "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -409,12 +456,21 @@ "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", - "loopTooltip" + "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ], "pl": [ "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -454,9 +510,6 @@ "createCollectionDialogAlbumDescription", "createCollectionDialogFolderLabel", "createCollectionDialogFolderDescription", - "convertAlbumTooltip", - "convertAlbumConfirmationDialogContent", - "convertAlbumSuccessNotification", "collectionFavoritesLabel", "favoriteTooltip", "favoriteSuccessNotification", @@ -539,12 +592,33 @@ "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", - "loopTooltip" + "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" + ], + + "pt": [ + "nameInputInvalidEmpty", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ], "ru": [ "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -648,12 +722,21 @@ "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", - "loopTooltip" + "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ], "zh": [ "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -757,12 +840,21 @@ "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", - "loopTooltip" + "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ], "zh_Hant": [ "connectingToServer2", "connectingToServerInstruction", + "nameInputInvalidEmpty", "signInHeaderText2", "settingsLanguageOptionSystemDefaultLabel", "settingsExifWifiOnlyTitle", @@ -866,6 +958,14 @@ "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", "initialSyncMessage", - "loopTooltip" + "loopTooltip", + "createCollectionFailureNotification", + "addItemToCollectionTooltip", + "addItemToCollectionFailureNotification", + "setCollectionCoverFailureNotification", + "exportCollectionTooltip", + "exportCollectionDialogTitle", + "createCollectionDialogNextcloudAlbumLabel", + "createCollectionDialogNextcloudAlbumDescription" ] } diff --git a/app/lib/web/notification.dart b/app/lib/web/notification.dart index 3815173c..cdaced47 100644 --- a/app/lib/web/notification.dart +++ b/app/lib/web/notification.dart @@ -14,7 +14,7 @@ class NotificationManager implements itf.NotificationManager { notify(itf.Notification n) async { if (n is itf.LogSaveSuccessfulNotification) { SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().downloadSuccessNotification), + content: Text(L10n.global().captureLogSuccessNotification), duration: k.snackBarDurationShort, )); } else { diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index 0d1227c3..a084529f 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -306,7 +306,7 @@ class _AlbumBrowserState extends State ), IconButton( icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, + tooltip: L10n.global().addItemToCollectionTooltip, onPressed: () => _onSelectionAddPressed(context), ), PopupMenuButton<_SelectionMenuOption>( diff --git a/app/lib/widget/album_browser_app_bar.dart b/app/lib/widget/album_browser_app_bar.dart index 2a0089e5..04f8b5c8 100644 --- a/app/lib/widget/album_browser_app_bar.dart +++ b/app/lib/widget/album_browser_app_bar.dart @@ -87,7 +87,7 @@ class _AlbumBrowserEditAppBarState extends State { if (_controller.text.isNotEmpty == true) { return null; } else { - return L10n.global().albumNameInputInvalidEmpty; + return L10n.global().nameInputInvalidEmpty; } }, onSaved: (_) { diff --git a/app/lib/widget/album_browser_mixin.dart b/app/lib/widget/album_browser_mixin.dart index 08e867c6..3ec6fd65 100644 --- a/app/lib/widget/album_browser_mixin.dart +++ b/app/lib/widget/album_browser_mixin.dart @@ -88,7 +88,7 @@ mixin AlbumBrowserMixin IconButton( onPressed: () => _onAddToCollectionPressed(context, account, album), icon: const Icon(Icons.library_add), - tooltip: L10n.global().addToCollectionTooltip, + tooltip: L10n.global().addToCollectionsViewTooltip, ), ...(actions ?? []), if (menuItemBuilder != null || menuItems.isNotEmpty) @@ -216,7 +216,7 @@ mixin AlbumBrowserMixin }, L10n.global().unsetAlbumCoverProcessingNotification, L10n.global().unsetAlbumCoverSuccessNotification, - failureText: L10n.global().unsetAlbumCoverFailureNotification, + failureText: L10n.global().setCollectionCoverFailureNotification, )(); } catch (e, stackTrace) { _log.shout( @@ -233,8 +233,8 @@ mixin AlbumBrowserMixin newAlbum = await ImportPendingSharedAlbum( KiwiContainer().resolve())(account, album); }, - L10n.global().addToCollectionProcessingNotification(album.name), - L10n.global().addToCollectionSuccessNotification(album.name), + L10n.global().addToCollectionsViewProcessingNotification(album.name), + L10n.global().addToCollectionsViewSuccessNotification(album.name), )(); } catch (e, stackTrace) { _log.shout( diff --git a/app/lib/widget/changelog.dart b/app/lib/widget/changelog.dart index b2af799e..1197b25b 100644 --- a/app/lib/widget/changelog.dart +++ b/app/lib/widget/changelog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/theme.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -40,7 +41,7 @@ class Changelog extends StatelessWidget { ); AppBar _buildAppBar() => AppBar( - title: const Text("Changelog"), + title: Text(L10n.global().changelogTitle), elevation: 0, ); diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index fc148692..69288e9d 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -46,9 +46,9 @@ class _AppBar extends StatelessWidget { value: _MenuOption.download, child: Text(L10n.global().downloadTooltip), ), - const PopupMenuItem( + PopupMenuItem( value: _MenuOption.export, - child: Text("Export"), + child: Text(L10n.global().exportCollectionTooltip), ), ], ], @@ -174,7 +174,7 @@ class _SelectionAppBar extends StatelessWidget { ), IconButton( icon: const Icon(Icons.add_outlined), - tooltip: L10n.global().addToAlbumTooltip, + tooltip: L10n.global().addItemToCollectionTooltip, onPressed: () { _onSelectionAddPressed(context); }, @@ -295,7 +295,7 @@ class _EditAppBar extends StatelessWidget { if (context.read<_Bloc>().state.currentEditName.isNotEmpty) { return null; } else { - return L10n.global().albumNameInputInvalidEmpty; + return L10n.global().nameInputInvalidEmpty; } }, onChanged: (value) { diff --git a/app/lib/widget/collection_picker.dart b/app/lib/widget/collection_picker.dart index 8f3bb81b..6d54c253 100644 --- a/app/lib/widget/collection_picker.dart +++ b/app/lib/widget/collection_picker.dart @@ -147,7 +147,7 @@ class _AppBar extends StatelessWidget { return _BlocBuilder( buildWhen: (previous, current) => previous.isLoading != current.isLoading, builder: (context, state) => SliverAppBar( - title: Text(L10n.global().addToAlbumTooltip), + title: Text(L10n.global().addItemToCollectionTooltip), floating: true, leading: state.isLoading ? const AppBarCircularProgressIndicator() : null, diff --git a/app/lib/widget/export_collection_dialog.dart b/app/lib/widget/export_collection_dialog.dart index c69f63d7..585414f4 100644 --- a/app/lib/widget/export_collection_dialog.dart +++ b/app/lib/widget/export_collection_dialog.dart @@ -91,7 +91,7 @@ class _WrappedExportCollectionDialogState ); } else { return AlertDialog( - title: const Text("Export collection"), + title: Text(L10n.global().exportCollectionDialogTitle), content: Form( key: _formKey, child: Container( @@ -148,7 +148,7 @@ class _NameTextField extends StatelessWidget { initialValue: context.read<_Bloc>().state.formValue.name, validator: (value) { if (value!.isEmpty) { - return L10n.global().albumNameInputInvalidEmpty; + return L10n.global().nameInputInvalidEmpty; } return null; }, diff --git a/app/lib/widget/handler/add_selection_to_collection_handler.dart b/app/lib/widget/handler/add_selection_to_collection_handler.dart index d9e97bac..de1cf1c5 100644 --- a/app/lib/widget/handler/add_selection_to_collection_handler.dart +++ b/app/lib/widget/handler/add_selection_to_collection_handler.dart @@ -44,7 +44,7 @@ class AddSelectionToCollectionHandler { } catch (e, stackTrace) { _log.shout("[call] Exception", e, stackTrace); SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().addToAlbumFailureNotification), + content: Text(L10n.global().addItemToCollectionFailureNotification), duration: k.snackBarDurationNormal, )); } diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 922cd7d0..465d98ec 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -237,7 +237,7 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> } catch (e, stacktrace) { _log.shout("[_onNewCollectionPressed] Failed", e, stacktrace); SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().createAlbumFailureNotification), + content: Text(L10n.global().createCollectionFailureNotification), duration: k.snackBarDurationNormal, )); } diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index aa6294d8..4a5b504a 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -270,7 +270,7 @@ class _HomePhotosState extends State ), IconButton( icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, + tooltip: L10n.global().addItemToCollectionTooltip, onPressed: () { _onSelectionAddToAlbumPressed(context); }, diff --git a/app/lib/widget/home_search.dart b/app/lib/widget/home_search.dart index 76911191..7696dec1 100644 --- a/app/lib/widget/home_search.dart +++ b/app/lib/widget/home_search.dart @@ -241,7 +241,7 @@ class _HomeSearchState extends State ), IconButton( icon: const Icon(Icons.add), - tooltip: L10n.global().addToAlbumTooltip, + tooltip: L10n.global().addItemToCollectionTooltip, onPressed: () => _onSelectionAddToAlbumPressed(context), ), PopupMenuButton<_SelectionMenuOption>( diff --git a/app/lib/widget/new_collection_dialog.dart b/app/lib/widget/new_collection_dialog.dart index 005c5418..51171cbc 100644 --- a/app/lib/widget/new_collection_dialog.dart +++ b/app/lib/widget/new_collection_dialog.dart @@ -193,7 +193,7 @@ class _NameTextField extends StatelessWidget { ), validator: (value) { if (value!.isEmpty) { - return L10n.global().albumNameInputInvalidEmpty; + return L10n.global().nameInputInvalidEmpty; } return null; }, @@ -261,7 +261,7 @@ enum _ProviderOption { case tag: return L10n.global().createCollectionDialogTagLabel; case ncAlbum: - return "Nextcloud Album"; + return L10n.global().createCollectionDialogNextcloudAlbumLabel; } } @@ -274,7 +274,7 @@ enum _ProviderOption { case tag: return L10n.global().createCollectionDialogTagDescription; case ncAlbum: - return "Server-side album, require Nextcloud 25 or above"; + return L10n.global().createCollectionDialogNextcloudAlbumDescription; } } } diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index 761234a2..3b121ef9 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -166,7 +166,7 @@ class _ViewerDetailPaneState extends State { ), _DetailPaneButton( icon: Icons.add, - label: L10n.global().addToAlbumTooltip, + label: L10n.global().addItemToCollectionTooltip, onPressed: () => _onAddToAlbumPressed(context), ), if (widget.fd.fdIsArchived == true) @@ -402,7 +402,7 @@ class _ViewerDetailPaneState extends State { _log.shout("[_onSetAlbumCoverPressed] Failed while updating album", e, stackTrace); SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().setAlbumCoverFailureNotification), + content: Text(L10n.global().setCollectionCoverFailureNotification), duration: k.snackBarDurationNormal, )); } From d2886e55c16eed532da9d44cb256d93533491849 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 2 May 2023 01:05:33 +0800 Subject: [PATCH 27/67] Regression: share and unshare album --- .../controller/collections_controller.dart | 54 ++++ app/lib/entity/collection.dart | 10 + app/lib/entity/collection/adapter.dart | 15 ++ ...d_only_adapter.dart => adapter_mixin.dart} | 30 ++- app/lib/entity/collection/adapter/album.dart | 50 ++++ .../collection/adapter/location_group.dart | 12 +- app/lib/entity/collection/adapter/memory.dart | 12 +- .../entity/collection/adapter/nc_album.dart | 5 +- app/lib/entity/collection/adapter/person.dart | 12 +- app/lib/entity/collection/adapter/tag.dart | 12 +- .../collection/content_provider/album.dart | 13 + .../content_provider/location_group.dart | 4 + .../collection/content_provider/memory.dart | 4 + .../collection/content_provider/nc_album.dart | 4 + .../collection/content_provider/person.dart | 4 + .../collection/content_provider/tag.dart | 4 + app/lib/entity/collection/util.dart | 27 ++ app/lib/entity/collection/util.g.dart | 14 ++ app/lib/exception.dart | 15 ++ app/lib/suggester.dart | 57 +++++ app/lib/suggester.g.dart | 14 ++ app/lib/use_case/album/remove_album.dart | 2 +- app/lib/use_case/album/remove_from_album.dart | 2 +- .../{ => album}/share_album_with_user.dart | 9 +- .../{ => album}/share_album_with_user.g.dart | 2 +- .../{ => album}/unshare_album_with_user.dart | 9 +- .../unshare_album_with_user.g.dart | 2 +- .../{ => album}/unshare_file_from_album.dart | 7 +- .../unshare_file_from_album.g.dart | 2 +- .../use_case/collection/share_collection.dart | 25 ++ .../collection/unshare_collection.dart | 25 ++ app/lib/widget/collection_browser.dart | 1 + .../widget/collection_browser/app_bar.dart | 18 ++ app/lib/widget/share_album_dialog.dart | 8 +- app/lib/widget/share_collection_dialog.dart | 237 ++++++++++++++++++ app/lib/widget/share_collection_dialog.g.dart | 116 +++++++++ .../widget/share_collection_dialog/bloc.dart | 147 +++++++++++ .../share_collection_dialog/state_event.dart | 96 +++++++ .../use_case/share_album_with_user_test.dart | 2 +- .../unshare_album_with_user_test.dart | 2 +- 40 files changed, 1032 insertions(+), 52 deletions(-) rename app/lib/entity/collection/adapter/{read_only_adapter.dart => adapter_mixin.dart} (63%) create mode 100644 app/lib/entity/collection/util.g.dart create mode 100644 app/lib/suggester.dart create mode 100644 app/lib/suggester.g.dart rename app/lib/use_case/{ => album}/share_album_with_user.dart (91%) rename app/lib/use_case/{ => album}/share_album_with_user.g.dart (84%) rename app/lib/use_case/{ => album}/unshare_album_with_user.dart (90%) rename app/lib/use_case/{ => album}/unshare_album_with_user.g.dart (83%) rename app/lib/use_case/{ => album}/unshare_file_from_album.dart (94%) rename app/lib/use_case/{ => album}/unshare_file_from_album.g.dart (83%) create mode 100644 app/lib/use_case/collection/share_collection.dart create mode 100644 app/lib/use_case/collection/unshare_collection.dart create mode 100644 app/lib/widget/share_collection_dialog.dart create mode 100644 app/lib/widget/share_collection_dialog.g.dart create mode 100644 app/lib/widget/share_collection_dialog/bloc.dart create mode 100644 app/lib/widget/share_collection_dialog/state_event.dart diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 7ec448fa..5777d4a5 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -8,16 +8,22 @@ 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/util.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/entity/sharee.dart'; +import 'package:nc_photos/exception.dart'; import 'package:nc_photos/or_null.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:nc_photos/use_case/collection/share_collection.dart'; +import 'package:nc_photos/use_case/collection/unshare_collection.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:rxdart/rxdart.dart'; @@ -183,6 +189,54 @@ class CollectionsController { } } + Future share(Collection collection, Sharee sharee) async { + try { + Collection? newCollection; + final result = await _mutex.protect(() async { + return await ShareCollection(_c)( + account, + collection, + sharee, + onCollectionUpdated: (c) { + newCollection = c; + }, + ); + }); + if (newCollection != null) { + _updateCollection(newCollection!); + } + if (result == CollectionShareResult.partial) { + _dataStreamController.addError(const CollectionPartialShareException()); + } + } catch (e, stackTrace) { + _dataStreamController.addError(e, stackTrace); + } + } + + Future unshare(Collection collection, CiString userId) async { + try { + Collection? newCollection; + final result = await _mutex.protect(() async { + return await UnshareCollection(_c)( + account, + collection, + userId, + onCollectionUpdated: (c) { + newCollection = c; + }, + ); + }); + if (newCollection != null) { + _updateCollection(newCollection!); + } + if (result == CollectionShareResult.partial) { + _dataStreamController.addError(const CollectionPartialShareException()); + } + } catch (e, stackTrace) { + _dataStreamController.addError(e, stackTrace); + } + } + Future _load() async { var lastData = const CollectionStreamEvent( data: [], diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index a9bdca0f..858860d3 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -1,5 +1,6 @@ import 'package:copy_with/copy_with.dart'; import 'package:equatable/equatable.dart'; +import 'package:nc_photos/entity/collection/util.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'; @@ -40,6 +41,9 @@ class Collection with EquatableMixin { /// See [CollectionContentProvider.itemSort] CollectionItemSort get itemSort => contentProvider.itemSort; + /// See [CollectionContentProvider.sharees] + List get shares => contentProvider.shares; + /// See [CollectionContentProvider.getCoverUrl] String? getCoverUrl( int width, @@ -77,6 +81,8 @@ enum CollectionCapability { labelItem, // set the cover image manualCover, + // share the collection with other user on the same server + share, } /// Provide the actual content of a collection @@ -106,6 +112,10 @@ abstract class CollectionContentProvider with EquatableMixin { /// Return the sort type CollectionItemSort get itemSort; + /// Return list of users who have access to this collection, excluding the + /// current user + List get shares; + /// Return the URL of the cover image if available /// /// The [width] and [height] are provided as a hint only, implementations are diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index db703281..a348e089 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -14,11 +14,14 @@ import 'package:nc_photos/entity/collection/content_provider/memory.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/util.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:nc_photos/entity/sharee.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; abstract class CollectionAdapter { @@ -71,6 +74,18 @@ abstract class CollectionAdapter { required ValueChanged onCollectionUpdated, }); + /// Share the collection with [sharee] + Future share( + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }); + + /// Unshare the collection with a user + Future unshare( + CiString userId, { + required ValueChanged onCollectionUpdated, + }); + /// Convert a [NewCollectionItem] to an adapted one Future adaptToNewItem(NewCollectionItem original); diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/adapter_mixin.dart similarity index 63% rename from app/lib/entity/collection/adapter/read_only_adapter.dart rename to app/lib/entity/collection/adapter/adapter_mixin.dart index e5766f18..8deda148 100644 --- a/app/lib/entity/collection/adapter/read_only_adapter.dart +++ b/app/lib/entity/collection/adapter/adapter_mixin.dart @@ -1,14 +1,17 @@ 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/util.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/entity/sharee.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; /// A read-only collection that does not support modifying its items -mixin CollectionReadOnlyAdapter implements CollectionAdapter { +mixin CollectionAdapterReadOnlyTag implements CollectionAdapter { @override Future addFiles( List files, { @@ -48,3 +51,28 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { Future updatePostLoad(List items) => Future.value(null); } + +mixin CollectionAdapterUnremovableTag implements CollectionAdapter { + @override + Future remove() { + throw UnsupportedError("Operation not supported"); + } +} + +mixin CollectionAdapterUnshareableTag implements CollectionAdapter { + @override + Future share( + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }) { + throw UnsupportedError("Operation not supported"); + } + + @override + Future unshare( + CiString userId, { + required ValueChanged onCollectionUpdated, + }) { + throw UnsupportedError("Operation not supported"); + } +} diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 36486eb5..b4b6352c 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -2,6 +2,7 @@ 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/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/item.dart'; @@ -10,12 +11,14 @@ 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/util.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/entity/sharee.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; @@ -23,9 +26,12 @@ 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/album/share_album_with_user.dart'; +import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:nc_photos/use_case/preprocess_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/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:tuple/tuple.dart'; @@ -173,6 +179,50 @@ class CollectionAlbumAdapter implements CollectionAdapter { } } + @override + Future share( + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }) async { + var fileFailed = false; + final newAlbum = await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)( + account, + _provider.album, + sharee, + onShareFileFailed: (f, e, stackTrace) { + _log.severe("[share] Failed to share file: ${logFilename(f.path)}", e, + stackTrace); + fileFailed = true; + }, + ); + onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum)); + return fileFailed + ? CollectionShareResult.partial + : CollectionShareResult.ok; + } + + @override + Future unshare( + CiString userId, { + required ValueChanged onCollectionUpdated, + }) async { + var fileFailed = false; + final newAlbum = await UnshareAlbumWithUser(_c)( + account, + _provider.album, + userId, + onUnshareFileFailed: (f, e, stackTrace) { + _log.severe("[unshare] Failed to unshare file: ${logFilename(f.path)}", + e, stackTrace); + fileFailed = true; + }, + ); + onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum)); + return fileFailed + ? CollectionShareResult.partial + : CollectionShareResult.ok; + } + @override Future adaptToNewItem(NewCollectionItem original) async { if (original is NewCollectionFileItem) { diff --git a/app/lib/entity/collection/adapter/location_group.dart b/app/lib/entity/collection/adapter/location_group.dart index 3f012101..e287dfe6 100644 --- a/app/lib/entity/collection/adapter/location_group.dart +++ b/app/lib/entity/collection/adapter/location_group.dart @@ -2,7 +2,7 @@ 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/adapter/adapter_mixin.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'; @@ -11,7 +11,10 @@ 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 + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionLocationGroupAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -43,11 +46,6 @@ class CollectionLocationGroupAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/adapter/memory.dart b/app/lib/entity/collection/adapter/memory.dart index 53a16a4d..eea3948f 100644 --- a/app/lib/entity/collection/adapter/memory.dart +++ b/app/lib/entity/collection/adapter/memory.dart @@ -2,7 +2,7 @@ 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/adapter/adapter_mixin.dart'; import 'package:nc_photos/entity/collection/content_provider/memory.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/basic_item.dart'; @@ -11,7 +11,10 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/use_case/list_location_file.dart'; class CollectionMemoryAdapter - with CollectionReadOnlyAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionMemoryAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -42,11 +45,6 @@ class CollectionMemoryAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index 28072cf7..d3f4cd5c 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -5,6 +5,7 @@ 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/adapter_mixin.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'; @@ -27,7 +28,9 @@ import 'package:np_common/type.dart'; part 'nc_album.g.dart'; @npLog -class CollectionNcAlbumAdapter implements CollectionAdapter { +class CollectionNcAlbumAdapter + with CollectionAdapterUnshareableTag + implements CollectionAdapter { CollectionNcAlbumAdapter(this._c, this.account, this.collection) : assert(require(_c)), _provider = collection.contentProvider as CollectionNcAlbumProvider; diff --git a/app/lib/entity/collection/adapter/person.dart b/app/lib/entity/collection/adapter/person.dart index 926b202d..a4527dd2 100644 --- a/app/lib/entity/collection/adapter/person.dart +++ b/app/lib/entity/collection/adapter/person.dart @@ -2,7 +2,7 @@ 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/adapter/adapter_mixin.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'; @@ -12,7 +12,10 @@ import 'package:nc_photos/use_case/list_face.dart'; import 'package:nc_photos/use_case/populate_person.dart'; class CollectionPersonAdapter - with CollectionReadOnlyAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionPersonAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -45,11 +48,6 @@ class CollectionPersonAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/adapter/tag.dart b/app/lib/entity/collection/adapter/tag.dart index 29135ac5..1226c181 100644 --- a/app/lib/entity/collection/adapter/tag.dart +++ b/app/lib/entity/collection/adapter/tag.dart @@ -2,14 +2,17 @@ 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/adapter/adapter_mixin.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 + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionTagAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -32,11 +35,6 @@ class CollectionTagAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index 9914fa90..93c62bad 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -5,6 +5,7 @@ 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/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:to_string/to_string.dart'; @@ -52,6 +53,7 @@ class CollectionAlbumProvider CollectionCapability.manualItem, CollectionCapability.manualSort, CollectionCapability.labelItem, + CollectionCapability.share, ], ]; @@ -66,6 +68,17 @@ class CollectionAlbumProvider @override CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort(); + @override + List get shares => + album.shares + ?.where((s) => s.userId != account.userId) + .map((s) => CollectionShare( + userId: s.userId, + username: s.displayName ?? s.userId.raw, + )) + .toList() ?? + const []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/location_group.dart b/app/lib/entity/collection/content_provider/location_group.dart index 6316eb99..cf809b48 100644 --- a/app/lib/entity/collection/content_provider/location_group.dart +++ b/app/lib/entity/collection/content_provider/location_group.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.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/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; @@ -31,6 +32,9 @@ class CollectionLocationGroupProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/memory.dart b/app/lib/entity/collection/content_provider/memory.dart index c8ed13c5..a8fcfdf3 100644 --- a/app/lib/entity/collection/content_provider/memory.dart +++ b/app/lib/entity/collection/content_provider/memory.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.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/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/object_extension.dart'; @@ -39,6 +40,9 @@ class CollectionMemoryProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index cd22d7fb..f2b00286 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.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/util.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'; @@ -45,6 +46,9 @@ class CollectionNcAlbumProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/person.dart b/app/lib/entity/collection/content_provider/person.dart index 16e7efae..f9be868c 100644 --- a/app/lib/entity/collection/content_provider/person.dart +++ b/app/lib/entity/collection/content_provider/person.dart @@ -5,6 +5,7 @@ import 'package:equatable/equatable.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/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/person.dart'; @@ -34,6 +35,9 @@ class CollectionPersonProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/tag.dart b/app/lib/entity/collection/content_provider/tag.dart index 23bc98f8..729160b7 100644 --- a/app/lib/entity/collection/content_provider/tag.dart +++ b/app/lib/entity/collection/content_provider/tag.dart @@ -2,6 +2,7 @@ import 'package:clock/clock.dart'; import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/tag.dart'; @@ -31,6 +32,9 @@ class CollectionTagProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/util.dart b/app/lib/entity/collection/util.dart index a8b7f07c..905cec22 100644 --- a/app/lib/entity/collection/util.dart +++ b/app/lib/entity/collection/util.dart @@ -1,7 +1,12 @@ import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:to_string/to_string.dart'; import 'package:tuple/tuple.dart'; +part 'util.g.dart'; + enum CollectionSort { dateDescending, dateAscending, @@ -14,6 +19,28 @@ enum CollectionSort { } } +@toString +class CollectionShare with EquatableMixin { + const CollectionShare({ + required this.userId, + required this.username, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [userId, username]; + + final CiString userId; + final String username; +} + +enum CollectionShareResult { + ok, + partial, +} + extension CollectionListExtension on Iterable { List sortedBy(CollectionSort by) { return map>((e) { diff --git a/app/lib/entity/collection/util.g.dart b/app/lib/entity/collection/util.g.dart new file mode 100644 index 00000000..ededd4c9 --- /dev/null +++ b/app/lib/entity/collection/util.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'util.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionShareToString on CollectionShare { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionShare {userId: $userId, username: $username}"; + } +} diff --git a/app/lib/exception.dart b/app/lib/exception.dart index 714edf01..5f7444b7 100644 --- a/app/lib/exception.dart +++ b/app/lib/exception.dart @@ -103,3 +103,18 @@ class AlbumItemPermissionException implements Exception { final dynamic message; } + +class CollectionPartialShareException implements Exception { + const CollectionPartialShareException([this.message]); + + @override + String toString() { + if (message == null) { + return "CollectionPartialShareException"; + } else { + return "CollectionPartialShareException: $message"; + } + } + + final dynamic message; +} diff --git a/app/lib/suggester.dart b/app/lib/suggester.dart new file mode 100644 index 00000000..20f0dc07 --- /dev/null +++ b/app/lib/suggester.dart @@ -0,0 +1,57 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:tuple/tuple.dart'; +import 'package:woozy_search/woozy_search.dart'; + +part 'suggester.g.dart'; + +@npLog +class Suggester { + Suggester({ + required this.items, + required this.itemToKeywords, + int maxResult = 5, + }) : _searcher = Woozy(limit: maxResult) { + for (final a in items) { + for (final k in itemToKeywords(a)) { + _searcher.addEntry(k.toCaseInsensitiveString(), value: a); + } + } + } + + List search(CiString phrase) { + final results = _searcher.search(phrase.toCaseInsensitiveString()); + if (kDebugMode) { + final str = results.map((e) => "${e.score}: ${e.text}").join("\n"); + _log.info("[search] Search '$phrase':\n$str"); + } + final matches = results + .where((e) => e.score > 0) + .map((e) { + if (itemToKeywords(e.value as T).any((k) => k.startsWith(phrase))) { + // prefer names that start exactly with the search phrase + return Tuple2(e.score + 1, e.value as T); + } else { + return Tuple2(e.score, e.value as T); + } + }) + .sorted((a, b) => a.item1.compareTo(b.item1)) + .reversed + .distinctIf( + (a, b) => identical(a.item2, b.item2), + (a) => a.item2.hashCode, + ) + .map((e) => e.item2) + .toList(); + return matches; + } + + final List items; + final List Function(T item) itemToKeywords; + + final Woozy _searcher; +} diff --git a/app/lib/suggester.g.dart b/app/lib/suggester.g.dart new file mode 100644 index 00000000..9f60e8d5 --- /dev/null +++ b/app/lib/suggester.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'suggester.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$SuggesterNpLog on Suggester { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("suggester.Suggester"); +} diff --git a/app/lib/use_case/album/remove_album.dart b/app/lib/use_case/album/remove_album.dart index 46f07573..b4c6c23f 100644 --- a/app/lib/use_case/album/remove_album.dart +++ b/app/lib/use_case/album/remove_album.dart @@ -6,10 +6,10 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/use_case/album/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/use_case/remove_share.dart'; -import 'package:nc_photos/use_case/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; diff --git a/app/lib/use_case/album/remove_from_album.dart b/app/lib/use_case/album/remove_from_album.dart index d9af6ad7..cf8deb56 100644 --- a/app/lib/use_case/album/remove_from_album.dart +++ b/app/lib/use_case/album/remove_from_album.dart @@ -10,8 +10,8 @@ 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/album/unshare_file_from_album.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'; diff --git a/app/lib/use_case/share_album_with_user.dart b/app/lib/use_case/album/share_album_with_user.dart similarity index 91% rename from app/lib/use_case/share_album_with_user.dart rename to app/lib/use_case/album/share_album_with_user.dart index 2b02beb2..342367c9 100644 --- a/app/lib/use_case/share_album_with_user.dart +++ b/app/lib/use_case/album/share_album_with_user.dart @@ -13,6 +13,7 @@ import 'package:nc_photos/use_case/create_share.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'share_album_with_user.g.dart'; @@ -24,7 +25,7 @@ class ShareAlbumWithUser { Account account, Album album, Sharee sharee, { - void Function(File)? onShareFileFailed, + ErrorWithValueHandler? onShareFileFailed, }) async { assert(album.provider is AlbumStaticProvider); final newShares = (album.shares ?? []) @@ -55,7 +56,7 @@ class ShareAlbumWithUser { Account account, Album album, CiString shareWith, { - void Function(File)? onShareFileFailed, + ErrorWithValueHandler? onShareFileFailed, }) async { final files = AlbumStaticProvider.of(album) .items @@ -70,7 +71,7 @@ class ShareAlbumWithUser { "[_createFileShares] Failed sharing album file '${logFilename(album.albumFile?.path)}' with '$shareWith'", e, stackTrace); - onShareFileFailed?.call(album.albumFile!); + onShareFileFailed?.call(album.albumFile!, e, stackTrace); } for (final f in files) { _log.info("[_createFileShares] Sharing '${f.path}' with '$shareWith'"); @@ -81,7 +82,7 @@ class ShareAlbumWithUser { "[_createFileShares] Failed sharing file '${logFilename(f.path)}' with '$shareWith'", e, stackTrace); - onShareFileFailed?.call(f); + onShareFileFailed?.call(f, e, stackTrace); } } } diff --git a/app/lib/use_case/share_album_with_user.g.dart b/app/lib/use_case/album/share_album_with_user.g.dart similarity index 84% rename from app/lib/use_case/share_album_with_user.g.dart rename to app/lib/use_case/album/share_album_with_user.g.dart index 33af6875..3fe70319 100644 --- a/app/lib/use_case/share_album_with_user.g.dart +++ b/app/lib/use_case/album/share_album_with_user.g.dart @@ -11,5 +11,5 @@ extension _$ShareAlbumWithUserNpLog on ShareAlbumWithUser { Logger get _log => log; static final log = - Logger("use_case.share_album_with_user.ShareAlbumWithUser"); + Logger("use_case.album.share_album_with_user.ShareAlbumWithUser"); } diff --git a/app/lib/use_case/unshare_album_with_user.dart b/app/lib/use_case/album/unshare_album_with_user.dart similarity index 90% rename from app/lib/use_case/unshare_album_with_user.dart rename to app/lib/use_case/album/unshare_album_with_user.dart index be9b1e26..58e8d620 100644 --- a/app/lib/use_case/unshare_album_with_user.dart +++ b/app/lib/use_case/album/unshare_album_with_user.dart @@ -7,12 +7,13 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/use_case/album/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/remove_share.dart'; -import 'package:nc_photos/use_case/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'unshare_album_with_user.g.dart'; @@ -31,7 +32,7 @@ class UnshareAlbumWithUser { Account account, Album album, CiString shareWith, { - void Function(Share)? onUnshareFileFailed, + ErrorWithValueHandler? onUnshareFileFailed, }) async { assert(album.provider is AlbumStaticProvider); // remove the share from album file @@ -59,7 +60,7 @@ class UnshareAlbumWithUser { Account account, Album album, CiString shareWith, { - void Function(Share)? onUnshareFileFailed, + ErrorWithValueHandler? onUnshareFileFailed, }) async { // remove share from the album file final albumShares = await ListShare(_c)(account, album.albumFile!); @@ -71,7 +72,7 @@ class UnshareAlbumWithUser { "[_deleteFileShares] Failed unsharing album file '${logFilename(album.albumFile?.path)}' with '$shareWith'", e, stackTrace); - onUnshareFileFailed?.call(s); + onUnshareFileFailed?.call(s, e, stackTrace); } } diff --git a/app/lib/use_case/unshare_album_with_user.g.dart b/app/lib/use_case/album/unshare_album_with_user.g.dart similarity index 83% rename from app/lib/use_case/unshare_album_with_user.g.dart rename to app/lib/use_case/album/unshare_album_with_user.g.dart index e80f8bff..852e860f 100644 --- a/app/lib/use_case/unshare_album_with_user.g.dart +++ b/app/lib/use_case/album/unshare_album_with_user.g.dart @@ -11,5 +11,5 @@ extension _$UnshareAlbumWithUserNpLog on UnshareAlbumWithUser { Logger get _log => log; static final log = - Logger("use_case.unshare_album_with_user.UnshareAlbumWithUser"); + Logger("use_case.album.unshare_album_with_user.UnshareAlbumWithUser"); } diff --git a/app/lib/use_case/unshare_file_from_album.dart b/app/lib/use_case/album/unshare_file_from_album.dart similarity index 94% rename from app/lib/use_case/unshare_file_from_album.dart rename to app/lib/use_case/album/unshare_file_from_album.dart index 94c609cf..3e205d86 100644 --- a/app/lib/use_case/unshare_file_from_album.dart +++ b/app/lib/use_case/album/unshare_file_from_album.dart @@ -14,6 +14,7 @@ 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'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'unshare_file_from_album.g.dart'; @@ -35,7 +36,7 @@ class UnshareFileFromAlbum { Album album, List files, List unshareWith, { - void Function(Share)? onUnshareFileFailed, + ErrorWithValueHandler? onUnshareFileFailed, }) async { _log.info( "[call] Unshare ${files.length} files from album '${album.name}' with ${unshareWith.length} users"); @@ -85,14 +86,14 @@ class UnshareFileFromAlbum { } Future _unshare(Account account, List shares, - void Function(Share)? onUnshareFileFailed) async { + ErrorWithValueHandler? onUnshareFileFailed) async { for (final s in shares) { try { await RemoveShare(_c.shareRepo)(account, s); } catch (e, stackTrace) { _log.severe( "[_unshare] Failed while RemoveShare: ${s.path}", e, stackTrace); - onUnshareFileFailed?.call(s); + onUnshareFileFailed?.call(s, e, stackTrace); } } } diff --git a/app/lib/use_case/unshare_file_from_album.g.dart b/app/lib/use_case/album/unshare_file_from_album.g.dart similarity index 83% rename from app/lib/use_case/unshare_file_from_album.g.dart rename to app/lib/use_case/album/unshare_file_from_album.g.dart index 89680f06..adaae550 100644 --- a/app/lib/use_case/unshare_file_from_album.g.dart +++ b/app/lib/use_case/album/unshare_file_from_album.g.dart @@ -11,5 +11,5 @@ extension _$UnshareFileFromAlbumNpLog on UnshareFileFromAlbum { Logger get _log => log; static final log = - Logger("use_case.unshare_file_from_album.UnshareFileFromAlbum"); + Logger("use_case.album.unshare_file_from_album.UnshareFileFromAlbum"); } diff --git a/app/lib/use_case/collection/share_collection.dart b/app/lib/use_case/collection/share_collection.dart new file mode 100644 index 00000000..58130aa2 --- /dev/null +++ b/app/lib/use_case/collection/share_collection.dart @@ -0,0 +1,25 @@ +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/util.dart'; +import 'package:nc_photos/entity/sharee.dart'; + +class ShareCollection { + const ShareCollection(this._c); + + /// Share the collection with [sharee] + Future call( + Account account, + Collection collection, + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }) => + CollectionAdapter.of(_c, account, collection).share( + sharee, + onCollectionUpdated: onCollectionUpdated, + ); + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/unshare_collection.dart b/app/lib/use_case/collection/unshare_collection.dart new file mode 100644 index 00000000..ed2a4270 --- /dev/null +++ b/app/lib/use_case/collection/unshare_collection.dart @@ -0,0 +1,25 @@ +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/util.dart'; +import 'package:np_common/ci_string.dart'; + +class UnshareCollection { + const UnshareCollection(this._c); + + /// Unshare the collection with a user + Future call( + Account account, + Collection collection, + CiString userId, { + required ValueChanged onCollectionUpdated, + }) => + CollectionAdapter.of(_c, account, collection).unshare( + userId, + onCollectionUpdated: onCollectionUpdated, + ); + + final DiContainer _c; +} diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 7cc7a4ba..050f0a10 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -52,6 +52,7 @@ 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/share_collection_dialog.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'; diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 69288e9d..97a2c9a3 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -16,6 +16,7 @@ class _AppBar extends StatelessWidget { final canRename = adapter.isPermitted(CollectionCapability.rename); final canManualCover = adapter.isPermitted(CollectionCapability.manualCover); + final canShare = adapter.isPermitted(CollectionCapability.share); final actions = [ ZoomMenuButton( @@ -26,6 +27,12 @@ class _AppBar extends StatelessWidget { context.read().setAlbumBrowserZoomLevel(value); }, ), + if (canShare) + IconButton( + onPressed: () => _onSharePressed(context), + icon: const Icon(Icons.share), + tooltip: L10n.global().shareTooltip, + ), ]; if (state.items.isNotEmpty || canRename) { actions.add(PopupMenuButton<_MenuOption>( @@ -107,6 +114,17 @@ class _AppBar extends StatelessWidget { Navigator.of(context).pop(); } } + + Future _onSharePressed(BuildContext context) async { + final bloc = context.read<_Bloc>(); + await showDialog( + context: context, + builder: (_) => ShareCollectionDialog( + account: bloc.account, + collection: bloc.state.collection, + ), + ); + } } class _AppBarCover extends StatelessWidget { diff --git a/app/lib/widget/share_album_dialog.dart b/app/lib/widget/share_album_dialog.dart index 785ca676..e406cc80 100644 --- a/app/lib/widget/share_album_dialog.dart +++ b/app/lib/widget/share_album_dialog.dart @@ -15,8 +15,8 @@ import 'package:nc_photos/entity/sharee.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/share_album_with_user.dart'; -import 'package:nc_photos/use_case/unshare_album_with_user.dart'; +import 'package:nc_photos/use_case/album/share_album_with_user.dart'; +import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/dialog_scaffold.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -240,7 +240,7 @@ class _ShareAlbumDialogState extends State { widget.account, _album, sharee, - onShareFileFailed: (_) { + onShareFileFailed: (_, __, ___) { hasFailure = true; }, ); @@ -279,7 +279,7 @@ class _ShareAlbumDialogState extends State { widget.account, _album, share.shareWith, - onUnshareFileFailed: (_) { + onUnshareFileFailed: (_, __, ___) { hasFailure = true; }, ); diff --git a/app/lib/widget/share_collection_dialog.dart b/app/lib/widget/share_collection_dialog.dart new file mode 100644 index 00000000..5dc2ebe5 --- /dev/null +++ b/app/lib/widget/share_collection_dialog.dart @@ -0,0 +1,237 @@ +import 'dart:async'; + +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_typeahead/flutter_typeahead.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/controller/account_controller.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; +import 'package:nc_photos/entity/sharee.dart'; +import 'package:nc_photos/exception.dart'; +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/snack_bar_manager.dart'; +import 'package:nc_photos/suggester.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:to_string/to_string.dart'; + +part 'share_collection_dialog.g.dart'; +part 'share_collection_dialog/bloc.dart'; +part 'share_collection_dialog/state_event.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; + +/// Dialog to share a new collection to other user on the same server +/// +/// Return the created collection, or null if user cancelled +class ShareCollectionDialog extends StatelessWidget { + const ShareCollectionDialog({ + super.key, + required this.account, + required this.collection, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + container: KiwiContainer().resolve(), + account: account, + collectionsController: + context.read().collectionsController, + collection: collection, + ), + child: const _WrappedShareCollectionDialog(), + ); + } + + final Account account; + final Collection collection; +} + +class _WrappedShareCollectionDialog extends StatefulWidget { + const _WrappedShareCollectionDialog(); + + @override + State createState() => _WrappedShareCollectionDialogState(); +} + +class _WrappedShareCollectionDialogState + extends State<_WrappedShareCollectionDialog> { + @override + void initState() { + super.initState(); + _bloc.add(const _LoadSharee()); + } + + @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 CollectionPartialShareException) { + // TODO localize string + SnackBarManager().showSnackBar(const SnackBar( + content: Text("Collection shared partially"), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + } + }, + ), + ], + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.collection != current.collection || + previous.processingShares != current.processingShares, + builder: (context, state) { + final shares = { + ...state.collection.shares, + ...state.processingShares, + }.sortedBy((e) => e.username); + return SimpleDialog( + title: Text(L10n.global().shareAlbumDialogTitle), + children: [ + ...shares.map((s) => _ShareView( + share: s, + isProcessing: state.processingShares.contains(s), + onPressed: () { + _bloc.add(_Unshare(s)); + }, + )), + const _ShareeInputView(), + ], + ); + }, + ), + ); + } + + late final _bloc = context.read<_Bloc>(); +} + +class _ShareeInputView extends StatefulWidget { + const _ShareeInputView(); + + @override + State createState() => _ShareeInputViewState(); +} + +class _ShareeInputViewState extends State<_ShareeInputView> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.shareeSuggester != current.shareeSuggester, + listener: (context, state) { + // search again + if (_lastPattern != null) { + _onSearch(_lastPattern!); + } + }, + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: TypeAheadField( + textFieldConfiguration: TextFieldConfiguration( + controller: _textController, + decoration: InputDecoration( + hintText: L10n.global().addUserInputHint, + ), + ), + direction: AxisDirection.up, + suggestionsCallback: _onSearch, + itemBuilder: (context, suggestion) => ListTile( + title: Text(suggestion.label), + subtitle: Text(suggestion.shareWith.toString()), + ), + onSuggestionSelected: _onSuggestionSelected, + hideOnEmpty: true, + hideOnLoading: true, + autoFlipDirection: true, + ), + ), + ); + } + + Iterable _onSearch(String pattern) { + _lastPattern = pattern; + final suggester = _bloc.state.shareeSuggester; + return suggester?.search(pattern.toCi()) ?? []; + } + + void _onSuggestionSelected(Sharee sharee) { + _textController.clear(); + _bloc.add(_Share(sharee)); + } + + late final _bloc = context.read<_Bloc>(); + final _textController = TextEditingController(); + + String? _lastPattern; +} + +class _ShareView extends StatelessWidget { + const _ShareView({ + required this.share, + required this.isProcessing, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final Widget trailing; + if (isProcessing) { + trailing = const Padding( + padding: EdgeInsetsDirectional.only(end: 12), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ), + ); + } else { + trailing = Checkbox( + value: true, + onChanged: (_) {}, + ); + } + return SimpleDialogOption( + onPressed: isProcessing ? null : onPressed, + child: ListTile( + title: Text(share.username), + subtitle: Text(share.userId.toString()), + // pass through the tap event + trailing: IgnorePointer( + child: trailing, + ), + ), + ); + } + + final CollectionShare share; + final bool isProcessing; + final VoidCallback? onPressed; +} diff --git a/app/lib/widget/share_collection_dialog.g.dart b/app/lib/widget/share_collection_dialog.g.dart new file mode 100644 index 00000000..cbe3acf4 --- /dev/null +++ b/app/lib/widget/share_collection_dialog.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'share_collection_dialog.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {Collection? collection, + List? processingShares, + List? sharees, + Suggester? shareeSuggester, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic collection, + dynamic processingShares, + dynamic sharees = copyWithNull, + dynamic shareeSuggester = copyWithNull, + dynamic error = copyWithNull}) { + return _State( + collection: collection as Collection? ?? that.collection, + processingShares: + processingShares as List? ?? that.processingShares, + sharees: + sharees == copyWithNull ? that.sharees : sharees as List?, + shareeSuggester: shareeSuggester == copyWithNull + ? that.shareeSuggester + : shareeSuggester as Suggester?, + 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 _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.share_collection_dialog._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {collection: $collection, processingShares: [length: ${processingShares.length}], sharees: ${sharees == null ? null : "[length: ${sharees!.length}]"}, shareeSuggester: $shareeSuggester, error: $error}"; + } +} + +extension _$_UpdateCollectionToString on _UpdateCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_UpdateCollection {collection: $collection}"; + } +} + +extension _$_LoadShareeToString on _LoadSharee { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadSharee {}"; + } +} + +extension _$_RefreshSuggesterToString on _RefreshSuggester { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RefreshSuggester {}"; + } +} + +extension _$_ShareToString on _Share { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Share {sharee: $sharee}"; + } +} + +extension _$_UnshareToString on _Unshare { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Unshare {share: $share}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/share_collection_dialog/bloc.dart b/app/lib/widget/share_collection_dialog/bloc.dart new file mode 100644 index 00000000..56ad2185 --- /dev/null +++ b/app/lib/widget/share_collection_dialog/bloc.dart @@ -0,0 +1,147 @@ +part of '../share_collection_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> { + _Bloc({ + required DiContainer container, + required this.account, + required this.collectionsController, + required Collection collection, + }) : _c = container, + super(_State.init( + collection: collection, + )) { + on<_UpdateCollection>(_onUpdateCollection); + on<_LoadSharee>(_onLoadSharee); + on<_RefreshSuggester>(_onRefreshSuggester); + + on<_ShareEventTag>((ev, emit) { + if (ev is _Share) { + return _onShare(ev, emit); + } else if (ev is _Unshare) { + return _onUnshare(ev, emit); + } else { + throw UnimplementedError(); + } + }); + + on<_SetError>(_onSetError); + + _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)); + } + }, + onError: (e, stackTrace) { + add(_SetError(e, stackTrace)); + }, + ); + } + + @override + Future close() { + _collectionControllerSubscription?.cancel(); + return super.close(); + } + + @override + void onChange(Change<_State> change) { + if (change.currentState.sharees != change.nextState.sharees || + change.currentState.collection != change.nextState.collection || + change.currentState.processingShares != + change.nextState.processingShares) { + add(const _RefreshSuggester()); + } + super.onChange(change); + } + + @override + void onError(Object error, StackTrace stackTrace) { + add(_SetError(error, stackTrace)); + super.onError(error, stackTrace); + } + + void _onUpdateCollection(_UpdateCollection ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(collection: ev.collection)); + } + + Future _onLoadSharee(_LoadSharee ev, Emitter<_State> emit) async { + _log.info(ev); + final sharees = await _c.shareeRepo.list(account); + emit(state.copyWith(sharees: sharees)); + } + + void _onRefreshSuggester(_RefreshSuggester ev, Emitter<_State> emit) { + _log.info(ev); + final searchable = state.sharees + ?.where((s) => + !state.collection.shares.any((e) => e.userId == s.shareWith)) + .where((s) => + !state.processingShares.any((e) => e.userId == s.shareWith)) + .where((s) => s.shareWith != account.userId) + .toList() ?? + []; + emit(state.copyWith( + shareeSuggester: Suggester( + items: searchable, + itemToKeywords: (item) => [item.shareWith, item.label.toCi()], + maxResult: 10, + ), + )); + } + + Future _onShare(_Share ev, Emitter<_State> emit) async { + _log.info(ev); + if (state.collection.shares.any((s) => s.userId == ev.sharee.shareWith) || + state.processingShares.any((s) => s.userId == ev.sharee.shareWith)) { + _log.fine("[_onShare] Already shared with sharee: ${ev.sharee}"); + return; + } + emit(state.copyWith( + processingShares: [ + ...state.processingShares, + CollectionShare( + userId: ev.sharee.shareWith, + username: ev.sharee.label, + ), + ], + )); + await collectionsController.share(state.collection, ev.sharee); + emit(state.copyWith( + processingShares: state.processingShares + .where((s) => s.userId != ev.sharee.shareWith) + .toList(), + )); + } + + Future _onUnshare(_Unshare ev, Emitter<_State> emit) async { + _log.info(ev); + emit(state.copyWith( + processingShares: [ + ...state.processingShares, + ev.share, + ], + )); + await collectionsController.unshare(state.collection, ev.share.userId); + emit(state.copyWith( + processingShares: state.processingShares + .where((s) => s.userId != ev.share.userId) + .toList(), + )); + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + final DiContainer _c; + final Account account; + final CollectionsController collectionsController; + + StreamSubscription? _collectionControllerSubscription; +} diff --git a/app/lib/widget/share_collection_dialog/state_event.dart b/app/lib/widget/share_collection_dialog/state_event.dart new file mode 100644 index 00000000..86eea090 --- /dev/null +++ b/app/lib/widget/share_collection_dialog/state_event.dart @@ -0,0 +1,96 @@ +part of '../share_collection_dialog.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.collection, + required this.processingShares, + this.sharees, + this.shareeSuggester, + this.error, + }); + + factory _State.init({ + required Collection collection, + }) { + return _State( + collection: collection, + processingShares: const [], + ); + } + + @override + String toString() => _$toString(); + + final Collection collection; + final List processingShares; + + final List? sharees; + final Suggester? shareeSuggester; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _UpdateCollection implements _Event { + const _UpdateCollection(this.collection); + + @override + String toString() => _$toString(); + + final Collection collection; +} + +@toString +class _LoadSharee implements _Event { + const _LoadSharee(); + + @override + String toString() => _$toString(); +} + +@toString +class _RefreshSuggester implements _Event { + const _RefreshSuggester(); + + @override + String toString() => _$toString(); +} + +mixin _ShareEventTag implements _Event {} + +@toString +class _Share with _ShareEventTag implements _Event { + const _Share(this.sharee); + + @override + String toString() => _$toString(); + + final Sharee sharee; +} + +@toString +class _Unshare with _ShareEventTag implements _Event { + const _Unshare(this.share); + + @override + String toString() => _$toString(); + + final CollectionShare share; +} + +@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/test/use_case/share_album_with_user_test.dart b/app/test/use_case/share_album_with_user_test.dart index e829689a..b313e836 100644 --- a/app/test/use_case/share_album_with_user_test.dart +++ b/app/test/use_case/share_album_with_user_test.dart @@ -1,7 +1,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/use_case/share_album_with_user.dart'; +import 'package:nc_photos/use_case/album/share_album_with_user.dart'; import 'package:np_common/ci_string.dart'; import 'package:test/test.dart'; diff --git a/app/test/use_case/unshare_album_with_user_test.dart b/app/test/use_case/unshare_album_with_user_test.dart index 00cd6a55..59e6cf5d 100644 --- a/app/test/use_case/unshare_album_with_user_test.dart +++ b/app/test/use_case/unshare_album_with_user_test.dart @@ -1,7 +1,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/use_case/unshare_album_with_user.dart'; +import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:np_common/ci_string.dart'; import 'package:test/test.dart'; From 4d9644ac186f02317de1d7244c3ba517b9eaeb1c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 7 May 2023 01:44:35 +0800 Subject: [PATCH 28/67] Correctly show shared items in a collaborative nc album --- app/lib/api/api_util.dart | 15 +- app/lib/api/entity_converter.dart | 59 ++- app/lib/api/entity_converter.g.dart | 4 +- .../entity/collection/adapter/nc_album.dart | 23 +- .../collection/content_provider/nc_album.dart | 8 +- .../nc_album_item_adapter.dart | 20 + .../nc_album_item_adapter.g.dart | 15 + app/lib/entity/nc_album.dart | 31 +- app/lib/entity/nc_album.g.dart | 7 + app/lib/entity/nc_album/data_source.dart | 70 ++- app/lib/entity/nc_album/item.dart | 5 - app/lib/entity/nc_album/repo.dart | 2 +- app/lib/entity/nc_album_item.dart | 95 ++++ app/lib/entity/nc_album_item.g.dart | 14 + app/lib/entity/sqlite/database.dart | 6 +- app/lib/entity/sqlite/database.g.dart | 465 +++++++++++++++++- app/lib/entity/sqlite/table.dart | 11 + app/lib/entity/sqlite/type_converter.dart | 56 ++- .../use_case/nc_album/list_nc_album_item.dart | 2 +- app/lib/widget/collection_browser.dart | 1 + app/lib/widget/collection_browser/type.dart | 16 +- app/lib/widget/network_thumbnail.dart | 9 + np_api/lib/np_api.dart | 1 + np_api/lib/src/entity/entity.dart | 73 ++- np_api/lib/src/entity/entity.g.dart | 16 +- .../lib/src/entity/nc_album_item_parser.dart | 140 ++++++ np_api/lib/src/entity/nc_album_parser.dart | 34 ++ np_api/test/entity/nc_album_parser_test.dart | 247 ++++++++++ 28 files changed, 1347 insertions(+), 98 deletions(-) create mode 100644 app/lib/entity/collection_item/nc_album_item_adapter.dart create mode 100644 app/lib/entity/collection_item/nc_album_item_adapter.g.dart delete mode 100644 app/lib/entity/nc_album/item.dart create mode 100644 app/lib/entity/nc_album_item.dart create mode 100644 app/lib/entity/nc_album_item.g.dart create mode 100644 np_api/lib/src/entity/nc_album_item_parser.dart create mode 100644 np_api/test/entity/nc_album_parser_test.dart diff --git a/app/lib/api/api_util.dart b/app/lib/api/api_util.dart index 7716b5ff..59988002 100644 --- a/app/lib/api/api_util.dart +++ b/app/lib/api/api_util.dart @@ -7,8 +7,9 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.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/nc_album_item.dart'; import 'package:nc_photos/exception.dart'; -import 'package:np_api/np_api.dart'; +import 'package:np_api/np_api.dart' hide NcAlbumItem; import 'package:to_string/to_string.dart'; part 'api_util.g.dart'; @@ -76,6 +77,18 @@ String getFilePreviewUrlByFileId( return url; } +/// Return the preview image URL for an item in [NcAlbum]. We can't use the +/// generic file preview url because collaborative albums do not create a file +/// share for photos not belonging to you, that means you can only access the +/// file view the Photos API +String getNcAlbumFilePreviewUrl( + Account account, + NcAlbumItem item, { + required int width, + required int height, +}) => + "${account.url}/apps/photos/api/v1/preview/${item.fileId}?x=$width&y=$height"; + String getFileUrl(Account account, FileDescriptor file) { return "${account.url}/${getFileUrlRelative(file)}"; } diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index 5afc0c5f..52b21522 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -5,6 +5,7 @@ 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/nc_album_item.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; @@ -35,7 +36,6 @@ class ApiFavoriteConverter { } } -@npLog class ApiFileConverter { static File fromApi(api.File file) { final metadata = file.customProperties?["com.nkming.nc_photos:metadata"] @@ -79,26 +79,12 @@ class ApiFileConverter { ?.run((obj) => ImageLocation.fromJson(jsonDecode(obj))), ); } - - static String _hrefToPath(String href) { - final rawPath = href.trimLeftAny("/"); - final pos = rawPath.indexOf("remote.php"); - if (pos == -1) { - // what? - _log.warning("[_hrefToPath] Unknown href value: $rawPath"); - return rawPath; - } else { - return rawPath.substring(pos); - } - } - - static final _log = _$ApiFileConverterNpLog.log; } class ApiNcAlbumConverter { static NcAlbum fromApi(api.NcAlbum album) { return NcAlbum( - path: album.href, + path: _hrefToPath(album.href), lastPhoto: (album.lastPhoto ?? -1) < 0 ? null : album.lastPhoto, nbItems: album.nbItems ?? 0, location: album.location, @@ -106,7 +92,30 @@ class ApiNcAlbumConverter { ?.run((d) => DateTime.fromMillisecondsSinceEpoch(d * 1000)), dateEnd: (album.dateRange?["end"] as int?) ?.run((d) => DateTime.fromMillisecondsSinceEpoch(d * 1000)), - collaborators: const [], + collaborators: album.collaborators + .map((c) => NcAlbumCollaborator( + id: c.id.toCi(), + label: c.label, + type: c.type, + )) + .toList(), + ); + } +} + +class ApiNcAlbumItemConverter { + static NcAlbumItem fromApi(api.NcAlbumItem item) { + return NcAlbumItem( + path: _hrefToPath(item.href), + fileId: item.fileId!, + contentLength: item.contentLength, + contentType: item.contentType, + etag: item.etag, + lastModified: item.lastModified, + hasPreview: item.hasPreview, + isFavorite: item.favorite, + fileMetadataWidth: item.fileMetadataSize?["width"], + fileMetadataHeight: item.fileMetadataSize?["height"], ); } } @@ -188,3 +197,19 @@ class ApiTaggedFileConverter { ); } } + +String _hrefToPath(String href) { + final rawPath = href.trimLeftAny("/"); + final pos = rawPath.indexOf("remote.php"); + if (pos == -1) { + // what? + _$_NpLog.log.warning("[_hrefToPath] Unknown href value: $rawPath"); + return rawPath; + } else { + return rawPath.substring(pos); + } +} + +@npLog +// ignore: camel_case_types +class _ {} diff --git a/app/lib/api/entity_converter.g.dart b/app/lib/api/entity_converter.g.dart index 52f114c6..dcfd8fe7 100644 --- a/app/lib/api/entity_converter.g.dart +++ b/app/lib/api/entity_converter.g.dart @@ -6,9 +6,9 @@ part of 'entity_converter.dart'; // NpLogGenerator // ************************************************************************** -extension _$ApiFileConverterNpLog on ApiFileConverter { +extension _$_NpLog on _ { // ignore: unused_element Logger get _log => log; - static final log = Logger("api.entity_converter.ApiFileConverter"); + static final log = Logger("api.entity_converter._"); } diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index d3f4cd5c..a2b19961 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -9,6 +9,7 @@ import 'package:nc_photos/entity/collection/adapter/adapter_mixin.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/nc_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_descriptor.dart'; @@ -41,14 +42,20 @@ class CollectionNcAlbumAdapter @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()); + .asyncMap((items) async { + final found = await FindFileDescriptor(_c)( + account, + items.map((e) => e.fileId).toList(), + onFileNotFound: (fileId) { + // happens when this is a file shared with you + _log.warning("[listItem] File not found: $fileId"); + }, + ); + return items.map((i) { + final f = found.firstWhereOrNull((e) => e.fdId == i.fileId); + return CollectionFileItemNcAlbumItemAdapter(i, f); + }).toList(); + }); } @override diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index f2b00286..044e9c00 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -41,13 +41,19 @@ class CollectionNcAlbumProvider List get capabilities => [ CollectionCapability.manualItem, CollectionCapability.rename, + // CollectionCapability.share, ]; @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; @override - List get shares => []; + List get shares => album.collaborators + .map((c) => CollectionShare( + userId: c.id, + username: c.label, + )) + .toList(); @override String? getCoverUrl( diff --git a/app/lib/entity/collection_item/nc_album_item_adapter.dart b/app/lib/entity/collection_item/nc_album_item_adapter.dart new file mode 100644 index 00000000..a497b1df --- /dev/null +++ b/app/lib/entity/collection_item/nc_album_item_adapter.dart @@ -0,0 +1,20 @@ +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album_item.dart'; +import 'package:to_string/to_string.dart'; + +part 'nc_album_item_adapter.g.dart'; + +@toString +class CollectionFileItemNcAlbumItemAdapter extends CollectionFileItem { + const CollectionFileItemNcAlbumItemAdapter(this.item, [this.localFile]); + + @override + String toString() => _$toString(); + + @override + FileDescriptor get file => localFile ?? item.toFile(); + + final NcAlbumItem item; + final FileDescriptor? localFile; +} diff --git a/app/lib/entity/collection_item/nc_album_item_adapter.g.dart b/app/lib/entity/collection_item/nc_album_item_adapter.g.dart new file mode 100644 index 00000000..383462d7 --- /dev/null +++ b/app/lib/entity/collection_item/nc_album_item_adapter.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nc_album_item_adapter.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionFileItemNcAlbumItemAdapterToString + on CollectionFileItemNcAlbumItemAdapter { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionFileItemNcAlbumItemAdapter {item: $item, localFile: ${localFile == null ? null : "${localFile!.fdPath}"}}"; + } +} diff --git a/app/lib/entity/nc_album.dart b/app/lib/entity/nc_album.dart index 9250c967..b54492de 100644 --- a/app/lib/entity/nc_album.dart +++ b/app/lib/entity/nc_album.dart @@ -1,7 +1,9 @@ import 'package:copy_with/copy_with.dart'; import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/string_extension.dart'; +import 'package:np_common/type.dart'; import 'package:to_string/to_string.dart'; part 'nc_album.g.dart'; @@ -109,4 +111,31 @@ extension NcAlbumExtension on NcAlbum { int get identityHashCode => path.hashCode; } -class NcAlbumCollaborator {} +@toString +class NcAlbumCollaborator { + const NcAlbumCollaborator({ + required this.id, + required this.label, + required this.type, + }); + + factory NcAlbumCollaborator.fromJson(JsonObj json) => NcAlbumCollaborator( + id: CiString(json["id"]), + label: json["label"], + type: json["type"], + ); + + JsonObj toJson() => { + "id": id.raw, + "label": label, + "type": type, + }; + + @override + String toString() => _$toString(); + + final CiString id; + final String label; + // right now it's unclear what this variable represents + final int type; +} diff --git a/app/lib/entity/nc_album.g.dart b/app/lib/entity/nc_album.g.dart index 727ad4a6..c3f7ca31 100644 --- a/app/lib/entity/nc_album.g.dart +++ b/app/lib/entity/nc_album.g.dart @@ -67,3 +67,10 @@ extension _$NcAlbumToString on NcAlbum { return "NcAlbum {path: $path, lastPhoto: $lastPhoto, nbItems: $nbItems, location: $location, dateStart: $dateStart, dateEnd: $dateEnd, collaborators: [length: ${collaborators.length}]}"; } } + +extension _$NcAlbumCollaboratorToString on NcAlbumCollaborator { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NcAlbumCollaborator {id: $id, label: $label, type: $type}"; + } +} diff --git a/app/lib/entity/nc_album/data_source.dart b/app/lib/entity/nc_album/data_source.dart index 85a5b4e3..1a1e48a9 100644 --- a/app/lib/entity/nc_album/data_source.dart +++ b/app/lib/entity/nc_album/data_source.dart @@ -4,8 +4,8 @@ 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/nc_album_item.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'; @@ -85,9 +85,21 @@ class NcAlbumRemoteDataSource implements NcAlbumDataSource { _log.info( "[getItems] account: ${account.userId}, album: ${album.strippedPath}"); final response = await ApiUtil.fromAccount(account).files().propfind( - path: album.path, - fileid: 1, - ); + path: album.path, + getlastmodified: 1, + getetag: 1, + getcontenttype: 1, + getcontentlength: 1, + hasPreview: 1, + fileid: 1, + favorite: 1, + customProperties: [ + "nc:file-metadata-size", + "nc:face-detections", + "nc:realpath", + "oc:permissions", + ], + ); if (!response.isGood) { _log.severe("[getItems] Failed requesting server: $response"); throw ApiException( @@ -96,11 +108,10 @@ class NcAlbumRemoteDataSource implements NcAlbumDataSource { ); } - final apiFiles = await api.FileParser().parse(response.body); + final apiFiles = await api.NcAlbumItemParser().parse(response.body); return apiFiles .where((f) => f.fileId != null) - .map(ApiFileConverter.fromApi) - .map((f) => NcAlbumItem(f.fileId!)) + .map(ApiNcAlbumItemConverter.fromApi) .toList(); } } @@ -161,7 +172,19 @@ class NcAlbumSqliteDbDataSource implements NcAlbumCacheDataSource { parentRelativePath: album.strippedPath, ); }); - return dbItems.map((i) => NcAlbumItem(i.fileId)).toList(); + return dbItems + .map((i) { + try { + return SqliteNcAlbumItemConverter.fromSql( + account.userId.toString(), album.strippedPath, i); + } catch (e, stackTrace) { + _log.severe( + "[getItems] Failed while converting DB entry", e, stackTrace); + return null; + } + }) + .whereNotNull() + .toList(); } @override @@ -213,25 +236,32 @@ class NcAlbumSqliteDbDataSource implements NcAlbumCacheDataSource { 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)), + final diff = list_util.diffWith( + existingItems + .map((e) => SqliteNcAlbumItemConverter.fromSql( + account.userId.raw, album.strippedPath, e)) + .sorted(NcAlbumItemExtension.identityComparator), + remote.sorted(NcAlbumItemExtension.identityComparator), + NcAlbumItemExtension.identityComparator, ); - if (idDiff.onlyInA.isNotEmpty || idDiff.onlyInB.isNotEmpty) { + if (diff.onlyInA.isNotEmpty || diff.onlyInB.isNotEmpty) { await db.batch((batch) async { - for (final id in idDiff.onlyInB) { + for (final item in diff.onlyInB) { // new batch.insert( db.ncAlbumItems, - SqliteNcAlbumItemConverter.toSql(dbAlbum, id), + SqliteNcAlbumItemConverter.toSql(dbAlbum, item), + ); + } + final rmIds = diff.onlyInA.map((e) => e.fileId).toList(); + if (rmIds.isNotEmpty) { + // removed + batch.deleteWhere( + db.ncAlbumItems, + (sql.$NcAlbumItemsTable t) => + t.parent.equals(dbAlbum.rowId) & t.fileId.isIn(rmIds), ); } - // removed - batch.deleteWhere( - db.ncAlbumItems, - (sql.$NcAlbumItemsTable t) => - t.parent.equals(dbAlbum.rowId) & t.fileId.isIn(idDiff.onlyInA), - ); }); } }); diff --git a/app/lib/entity/nc_album/item.dart b/app/lib/entity/nc_album/item.dart deleted file mode 100644 index bb7bd76b..00000000 --- a/app/lib/entity/nc_album/item.dart +++ /dev/null @@ -1,5 +0,0 @@ -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 index d27a124a..fa95e1d6 100644 --- a/app/lib/entity/nc_album/repo.dart +++ b/app/lib/entity/nc_album/repo.dart @@ -3,7 +3,7 @@ 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:nc_photos/entity/nc_album_item.dart'; import 'package:np_codegen/np_codegen.dart'; part 'repo.g.dart'; diff --git a/app/lib/entity/nc_album_item.dart b/app/lib/entity/nc_album_item.dart new file mode 100644 index 00000000..1ef40c93 --- /dev/null +++ b/app/lib/entity/nc_album_item.dart @@ -0,0 +1,95 @@ +import 'package:nc_photos/entity/file.dart'; +import 'package:np_common/string_extension.dart'; +import 'package:to_string/to_string.dart'; + +part 'nc_album_item.g.dart'; + +@ToString(ignoreNull: true) +class NcAlbumItem { + const NcAlbumItem({ + required this.path, + required this.fileId, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.hasPreview, + this.isFavorite, + this.fileMetadataWidth, + this.fileMetadataHeight, + }); + + @override + String toString() => _$toString(); + + final String path; + final int fileId; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final bool? hasPreview; + final bool? isFavorite; + final int? fileMetadataWidth; + final int? fileMetadataHeight; +} + +extension NcAlbumItemExtension on NcAlbumItem { + /// Return the path of this file with the DAV part stripped + /// + /// WebDAV file path: remote.php/dav/photos/{userId}/albums/{album}/{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; + begin = path.indexOf("/", begin); + if (begin == -1) { + return path; + } + final stripped = path.slice(begin + 1); + if (stripped.isEmpty) { + return "."; + } else { + return stripped; + } + } + + bool compareIdentity(NcAlbumItem other) => fileId == other.fileId; + + int get identityHashCode => fileId.hashCode; + + static int identityComparator(NcAlbumItem a, NcAlbumItem b) => + a.fileId.compareTo(b.fileId); + + File toFile() { + Metadata? metadata; + if (fileMetadataWidth != null && fileMetadataHeight != null) { + metadata = Metadata( + imageWidth: fileMetadataWidth, + imageHeight: fileMetadataHeight, + ); + } + return File( + path: path, + fileId: fileId, + contentLength: contentLength, + contentType: contentType, + etag: etag, + lastModified: lastModified, + hasPreview: hasPreview, + isFavorite: isFavorite, + metadata: metadata, + ); + } +} diff --git a/app/lib/entity/nc_album_item.g.dart b/app/lib/entity/nc_album_item.g.dart new file mode 100644 index 00000000..1250127c --- /dev/null +++ b/app/lib/entity/nc_album_item.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nc_album_item.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$NcAlbumItemToString on NcAlbumItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NcAlbumItem {path: $path, fileId: $fileId, ${contentLength == null ? "" : "contentLength: $contentLength, "}${contentType == null ? "" : "contentType: $contentType, "}${etag == null ? "" : "etag: $etag, "}${lastModified == null ? "" : "lastModified: $lastModified, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${isFavorite == null ? "" : "isFavorite: $isFavorite, "}${fileMetadataWidth == null ? "" : "fileMetadataWidth: $fileMetadataWidth, "}${fileMetadataHeight == null ? "" : "fileMetadataHeight: $fileMetadataHeight"}}"; + } +} diff --git a/app/lib/entity/sqlite/database.dart b/app/lib/entity/sqlite/database.dart index 4bd8ad6a..7d2259db 100644 --- a/app/lib/entity/sqlite/database.dart +++ b/app/lib/entity/sqlite/database.dart @@ -49,7 +49,7 @@ class SqliteDb extends _$SqliteDb { SqliteDb.connect(DatabaseConnection connection) : super.connect(connection); @override - get schemaVersion => 4; + get schemaVersion => 5; @override get migration => MigrationStrategy( @@ -98,6 +98,10 @@ class SqliteDb extends _$SqliteDb { if (from < 4) { await m.addColumn(albums, albums.fileEtag); } + if (from < 5) { + await m.createTable(ncAlbums); + await m.createTable(ncAlbumItems); + } }); } catch (e, stackTrace) { _log.shout("[onUpgrade] Failed upgrading sqlite db", e, stackTrace); diff --git a/app/lib/entity/sqlite/database.g.dart b/app/lib/entity/sqlite/database.g.dart index a1de07c5..c987f095 100644 --- a/app/lib/entity/sqlite/database.g.dart +++ b/app/lib/entity/sqlite/database.g.dart @@ -4166,6 +4166,7 @@ class NcAlbum extends DataClass implements Insertable { final String? location; final DateTime? dateStart; final DateTime? dateEnd; + final String collaborators; NcAlbum( {required this.rowId, required this.account, @@ -4174,7 +4175,8 @@ class NcAlbum extends DataClass implements Insertable { required this.nbItems, this.location, this.dateStart, - this.dateEnd}); + this.dateEnd, + required this.collaborators}); factory NcAlbum.fromData(Map data, {String? prefix}) { final effectivePrefix = prefix ?? ''; return NcAlbum( @@ -4194,6 +4196,8 @@ class NcAlbum extends DataClass implements Insertable { .mapFromDatabaseResponse(data['${effectivePrefix}date_start'])), dateEnd: $NcAlbumsTable.$converter1.mapToDart(const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}date_end'])), + collaborators: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}collaborators'])!, ); } @override @@ -4217,6 +4221,7 @@ class NcAlbum extends DataClass implements Insertable { final converter = $NcAlbumsTable.$converter1; map['date_end'] = Variable(converter.mapToSql(dateEnd)); } + map['collaborators'] = Variable(collaborators); return map; } @@ -4238,6 +4243,7 @@ class NcAlbum extends DataClass implements Insertable { dateEnd: dateEnd == null && nullToAbsent ? const Value.absent() : Value(dateEnd), + collaborators: Value(collaborators), ); } @@ -4253,6 +4259,7 @@ class NcAlbum extends DataClass implements Insertable { location: serializer.fromJson(json['location']), dateStart: serializer.fromJson(json['dateStart']), dateEnd: serializer.fromJson(json['dateEnd']), + collaborators: serializer.fromJson(json['collaborators']), ); } @override @@ -4267,6 +4274,7 @@ class NcAlbum extends DataClass implements Insertable { 'location': serializer.toJson(location), 'dateStart': serializer.toJson(dateStart), 'dateEnd': serializer.toJson(dateEnd), + 'collaborators': serializer.toJson(collaborators), }; } @@ -4278,7 +4286,8 @@ class NcAlbum extends DataClass implements Insertable { int? nbItems, Value location = const Value.absent(), Value dateStart = const Value.absent(), - Value dateEnd = const Value.absent()}) => + Value dateEnd = const Value.absent(), + String? collaborators}) => NcAlbum( rowId: rowId ?? this.rowId, account: account ?? this.account, @@ -4288,6 +4297,7 @@ class NcAlbum extends DataClass implements Insertable { location: location.present ? location.value : this.location, dateStart: dateStart.present ? dateStart.value : this.dateStart, dateEnd: dateEnd.present ? dateEnd.value : this.dateEnd, + collaborators: collaborators ?? this.collaborators, ); @override String toString() { @@ -4299,14 +4309,15 @@ class NcAlbum extends DataClass implements Insertable { ..write('nbItems: $nbItems, ') ..write('location: $location, ') ..write('dateStart: $dateStart, ') - ..write('dateEnd: $dateEnd') + ..write('dateEnd: $dateEnd, ') + ..write('collaborators: $collaborators') ..write(')')) .toString(); } @override int get hashCode => Object.hash(rowId, account, relativePath, lastPhoto, - nbItems, location, dateStart, dateEnd); + nbItems, location, dateStart, dateEnd, collaborators); @override bool operator ==(Object other) => identical(this, other) || @@ -4318,7 +4329,8 @@ class NcAlbum extends DataClass implements Insertable { other.nbItems == this.nbItems && other.location == this.location && other.dateStart == this.dateStart && - other.dateEnd == this.dateEnd); + other.dateEnd == this.dateEnd && + other.collaborators == this.collaborators); } class NcAlbumsCompanion extends UpdateCompanion { @@ -4330,6 +4342,7 @@ class NcAlbumsCompanion extends UpdateCompanion { final Value location; final Value dateStart; final Value dateEnd; + final Value collaborators; const NcAlbumsCompanion({ this.rowId = const Value.absent(), this.account = const Value.absent(), @@ -4339,6 +4352,7 @@ class NcAlbumsCompanion extends UpdateCompanion { this.location = const Value.absent(), this.dateStart = const Value.absent(), this.dateEnd = const Value.absent(), + this.collaborators = const Value.absent(), }); NcAlbumsCompanion.insert({ this.rowId = const Value.absent(), @@ -4349,9 +4363,11 @@ class NcAlbumsCompanion extends UpdateCompanion { this.location = const Value.absent(), this.dateStart = const Value.absent(), this.dateEnd = const Value.absent(), + required String collaborators, }) : account = Value(account), relativePath = Value(relativePath), - nbItems = Value(nbItems); + nbItems = Value(nbItems), + collaborators = Value(collaborators); static Insertable custom({ Expression? rowId, Expression? account, @@ -4361,6 +4377,7 @@ class NcAlbumsCompanion extends UpdateCompanion { Expression? location, Expression? dateStart, Expression? dateEnd, + Expression? collaborators, }) { return RawValuesInsertable({ if (rowId != null) 'row_id': rowId, @@ -4371,6 +4388,7 @@ class NcAlbumsCompanion extends UpdateCompanion { if (location != null) 'location': location, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, + if (collaborators != null) 'collaborators': collaborators, }); } @@ -4382,7 +4400,8 @@ class NcAlbumsCompanion extends UpdateCompanion { Value? nbItems, Value? location, Value? dateStart, - Value? dateEnd}) { + Value? dateEnd, + Value? collaborators}) { return NcAlbumsCompanion( rowId: rowId ?? this.rowId, account: account ?? this.account, @@ -4392,6 +4411,7 @@ class NcAlbumsCompanion extends UpdateCompanion { location: location ?? this.location, dateStart: dateStart ?? this.dateStart, dateEnd: dateEnd ?? this.dateEnd, + collaborators: collaborators ?? this.collaborators, ); } @@ -4425,6 +4445,9 @@ class NcAlbumsCompanion extends UpdateCompanion { final converter = $NcAlbumsTable.$converter1; map['date_end'] = Variable(converter.mapToSql(dateEnd.value)); } + if (collaborators.present) { + map['collaborators'] = Variable(collaborators.value); + } return map; } @@ -4438,7 +4461,8 @@ class NcAlbumsCompanion extends UpdateCompanion { ..write('nbItems: $nbItems, ') ..write('location: $location, ') ..write('dateStart: $dateStart, ') - ..write('dateEnd: $dateEnd') + ..write('dateEnd: $dateEnd, ') + ..write('collaborators: $collaborators') ..write(')')) .toString(); } @@ -4496,6 +4520,12 @@ class $NcAlbumsTable extends NcAlbums with TableInfo<$NcAlbumsTable, NcAlbum> { GeneratedColumn('date_end', aliasedName, true, type: const IntType(), requiredDuringInsert: false) .withConverter($NcAlbumsTable.$converter1); + final VerificationMeta _collaboratorsMeta = + const VerificationMeta('collaborators'); + @override + late final GeneratedColumn collaborators = GeneratedColumn( + 'collaborators', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); @override List get $columns => [ rowId, @@ -4505,7 +4535,8 @@ class $NcAlbumsTable extends NcAlbums with TableInfo<$NcAlbumsTable, NcAlbum> { nbItems, location, dateStart, - dateEnd + dateEnd, + collaborators ]; @override String get aliasedName => _alias ?? 'nc_albums'; @@ -4550,6 +4581,14 @@ class $NcAlbumsTable extends NcAlbums with TableInfo<$NcAlbumsTable, NcAlbum> { } context.handle(_dateStartMeta, const VerificationResult.success()); context.handle(_dateEndMeta, const VerificationResult.success()); + if (data.containsKey('collaborators')) { + context.handle( + _collaboratorsMeta, + collaborators.isAcceptableOrUnknown( + data['collaborators']!, _collaboratorsMeta)); + } else if (isInserting) { + context.missing(_collaboratorsMeta); + } return context; } @@ -4579,9 +4618,29 @@ class $NcAlbumsTable extends NcAlbums with TableInfo<$NcAlbumsTable, NcAlbum> { class NcAlbumItem extends DataClass implements Insertable { final int rowId; final int parent; + final String relativePath; final int fileId; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final bool? hasPreview; + final bool? isFavorite; + final int? fileMetadataWidth; + final int? fileMetadataHeight; NcAlbumItem( - {required this.rowId, required this.parent, required this.fileId}); + {required this.rowId, + required this.parent, + required this.relativePath, + required this.fileId, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.hasPreview, + this.isFavorite, + this.fileMetadataWidth, + this.fileMetadataHeight}); factory NcAlbumItem.fromData(Map data, {String? prefix}) { final effectivePrefix = prefix ?? ''; return NcAlbumItem( @@ -4589,8 +4648,27 @@ class NcAlbumItem extends DataClass implements Insertable { .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, parent: const IntType() .mapFromDatabaseResponse(data['${effectivePrefix}parent'])!, + relativePath: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}relative_path'])!, fileId: const IntType() .mapFromDatabaseResponse(data['${effectivePrefix}file_id'])!, + contentLength: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}content_length']), + contentType: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}content_type']), + etag: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}etag']), + lastModified: $NcAlbumItemsTable.$converter0.mapToDart( + const DateTimeType().mapFromDatabaseResponse( + data['${effectivePrefix}last_modified'])), + hasPreview: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}has_preview']), + isFavorite: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}is_favorite']), + fileMetadataWidth: const IntType().mapFromDatabaseResponse( + data['${effectivePrefix}file_metadata_width']), + fileMetadataHeight: const IntType().mapFromDatabaseResponse( + data['${effectivePrefix}file_metadata_height']), ); } @override @@ -4598,7 +4676,34 @@ class NcAlbumItem extends DataClass implements Insertable { final map = {}; map['row_id'] = Variable(rowId); map['parent'] = Variable(parent); + map['relative_path'] = Variable(relativePath); map['file_id'] = Variable(fileId); + if (!nullToAbsent || contentLength != null) { + map['content_length'] = Variable(contentLength); + } + if (!nullToAbsent || contentType != null) { + map['content_type'] = Variable(contentType); + } + if (!nullToAbsent || etag != null) { + map['etag'] = Variable(etag); + } + if (!nullToAbsent || lastModified != null) { + final converter = $NcAlbumItemsTable.$converter0; + map['last_modified'] = + Variable(converter.mapToSql(lastModified)); + } + if (!nullToAbsent || hasPreview != null) { + map['has_preview'] = Variable(hasPreview); + } + if (!nullToAbsent || isFavorite != null) { + map['is_favorite'] = Variable(isFavorite); + } + if (!nullToAbsent || fileMetadataWidth != null) { + map['file_metadata_width'] = Variable(fileMetadataWidth); + } + if (!nullToAbsent || fileMetadataHeight != null) { + map['file_metadata_height'] = Variable(fileMetadataHeight); + } return map; } @@ -4606,7 +4711,30 @@ class NcAlbumItem extends DataClass implements Insertable { return NcAlbumItemsCompanion( rowId: Value(rowId), parent: Value(parent), + relativePath: Value(relativePath), fileId: Value(fileId), + contentLength: contentLength == null && nullToAbsent + ? const Value.absent() + : Value(contentLength), + contentType: contentType == null && nullToAbsent + ? const Value.absent() + : Value(contentType), + etag: etag == null && nullToAbsent ? const Value.absent() : Value(etag), + lastModified: lastModified == null && nullToAbsent + ? const Value.absent() + : Value(lastModified), + hasPreview: hasPreview == null && nullToAbsent + ? const Value.absent() + : Value(hasPreview), + isFavorite: isFavorite == null && nullToAbsent + ? const Value.absent() + : Value(isFavorite), + fileMetadataWidth: fileMetadataWidth == null && nullToAbsent + ? const Value.absent() + : Value(fileMetadataWidth), + fileMetadataHeight: fileMetadataHeight == null && nullToAbsent + ? const Value.absent() + : Value(fileMetadataHeight), ); } @@ -4616,7 +4744,16 @@ class NcAlbumItem extends DataClass implements Insertable { return NcAlbumItem( rowId: serializer.fromJson(json['rowId']), parent: serializer.fromJson(json['parent']), + relativePath: serializer.fromJson(json['relativePath']), fileId: serializer.fromJson(json['fileId']), + contentLength: serializer.fromJson(json['contentLength']), + contentType: serializer.fromJson(json['contentType']), + etag: serializer.fromJson(json['etag']), + lastModified: serializer.fromJson(json['lastModified']), + hasPreview: serializer.fromJson(json['hasPreview']), + isFavorite: serializer.fromJson(json['isFavorite']), + fileMetadataWidth: serializer.fromJson(json['fileMetadataWidth']), + fileMetadataHeight: serializer.fromJson(json['fileMetadataHeight']), ); } @override @@ -4625,69 +4762,203 @@ class NcAlbumItem extends DataClass implements Insertable { return { 'rowId': serializer.toJson(rowId), 'parent': serializer.toJson(parent), + 'relativePath': serializer.toJson(relativePath), 'fileId': serializer.toJson(fileId), + 'contentLength': serializer.toJson(contentLength), + 'contentType': serializer.toJson(contentType), + 'etag': serializer.toJson(etag), + 'lastModified': serializer.toJson(lastModified), + 'hasPreview': serializer.toJson(hasPreview), + 'isFavorite': serializer.toJson(isFavorite), + 'fileMetadataWidth': serializer.toJson(fileMetadataWidth), + 'fileMetadataHeight': serializer.toJson(fileMetadataHeight), }; } - NcAlbumItem copyWith({int? rowId, int? parent, int? fileId}) => NcAlbumItem( + NcAlbumItem copyWith( + {int? rowId, + int? parent, + String? relativePath, + int? fileId, + Value contentLength = const Value.absent(), + Value contentType = const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value hasPreview = const Value.absent(), + Value isFavorite = const Value.absent(), + Value fileMetadataWidth = const Value.absent(), + Value fileMetadataHeight = const Value.absent()}) => + NcAlbumItem( rowId: rowId ?? this.rowId, parent: parent ?? this.parent, + relativePath: relativePath ?? this.relativePath, fileId: fileId ?? this.fileId, + contentLength: + contentLength.present ? contentLength.value : this.contentLength, + contentType: contentType.present ? contentType.value : this.contentType, + etag: etag.present ? etag.value : this.etag, + lastModified: + lastModified.present ? lastModified.value : this.lastModified, + hasPreview: hasPreview.present ? hasPreview.value : this.hasPreview, + isFavorite: isFavorite.present ? isFavorite.value : this.isFavorite, + fileMetadataWidth: fileMetadataWidth.present + ? fileMetadataWidth.value + : this.fileMetadataWidth, + fileMetadataHeight: fileMetadataHeight.present + ? fileMetadataHeight.value + : this.fileMetadataHeight, ); @override String toString() { return (StringBuffer('NcAlbumItem(') ..write('rowId: $rowId, ') ..write('parent: $parent, ') - ..write('fileId: $fileId') + ..write('relativePath: $relativePath, ') + ..write('fileId: $fileId, ') + ..write('contentLength: $contentLength, ') + ..write('contentType: $contentType, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('hasPreview: $hasPreview, ') + ..write('isFavorite: $isFavorite, ') + ..write('fileMetadataWidth: $fileMetadataWidth, ') + ..write('fileMetadataHeight: $fileMetadataHeight') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(rowId, parent, fileId); + int get hashCode => Object.hash( + rowId, + parent, + relativePath, + fileId, + contentLength, + contentType, + etag, + lastModified, + hasPreview, + isFavorite, + fileMetadataWidth, + fileMetadataHeight); @override bool operator ==(Object other) => identical(this, other) || (other is NcAlbumItem && other.rowId == this.rowId && other.parent == this.parent && - other.fileId == this.fileId); + other.relativePath == this.relativePath && + other.fileId == this.fileId && + other.contentLength == this.contentLength && + other.contentType == this.contentType && + other.etag == this.etag && + other.lastModified == this.lastModified && + other.hasPreview == this.hasPreview && + other.isFavorite == this.isFavorite && + other.fileMetadataWidth == this.fileMetadataWidth && + other.fileMetadataHeight == this.fileMetadataHeight); } class NcAlbumItemsCompanion extends UpdateCompanion { final Value rowId; final Value parent; + final Value relativePath; final Value fileId; + final Value contentLength; + final Value contentType; + final Value etag; + final Value lastModified; + final Value hasPreview; + final Value isFavorite; + final Value fileMetadataWidth; + final Value fileMetadataHeight; const NcAlbumItemsCompanion({ this.rowId = const Value.absent(), this.parent = const Value.absent(), + this.relativePath = const Value.absent(), this.fileId = const Value.absent(), + this.contentLength = const Value.absent(), + this.contentType = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.hasPreview = const Value.absent(), + this.isFavorite = const Value.absent(), + this.fileMetadataWidth = const Value.absent(), + this.fileMetadataHeight = const Value.absent(), }); NcAlbumItemsCompanion.insert({ this.rowId = const Value.absent(), required int parent, + required String relativePath, required int fileId, + this.contentLength = const Value.absent(), + this.contentType = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.hasPreview = const Value.absent(), + this.isFavorite = const Value.absent(), + this.fileMetadataWidth = const Value.absent(), + this.fileMetadataHeight = const Value.absent(), }) : parent = Value(parent), + relativePath = Value(relativePath), fileId = Value(fileId); static Insertable custom({ Expression? rowId, Expression? parent, + Expression? relativePath, Expression? fileId, + Expression? contentLength, + Expression? contentType, + Expression? etag, + Expression? lastModified, + Expression? hasPreview, + Expression? isFavorite, + Expression? fileMetadataWidth, + Expression? fileMetadataHeight, }) { return RawValuesInsertable({ if (rowId != null) 'row_id': rowId, if (parent != null) 'parent': parent, + if (relativePath != null) 'relative_path': relativePath, if (fileId != null) 'file_id': fileId, + if (contentLength != null) 'content_length': contentLength, + if (contentType != null) 'content_type': contentType, + if (etag != null) 'etag': etag, + if (lastModified != null) 'last_modified': lastModified, + if (hasPreview != null) 'has_preview': hasPreview, + if (isFavorite != null) 'is_favorite': isFavorite, + if (fileMetadataWidth != null) 'file_metadata_width': fileMetadataWidth, + if (fileMetadataHeight != null) + 'file_metadata_height': fileMetadataHeight, }); } NcAlbumItemsCompanion copyWith( - {Value? rowId, Value? parent, Value? fileId}) { + {Value? rowId, + Value? parent, + Value? relativePath, + Value? fileId, + Value? contentLength, + Value? contentType, + Value? etag, + Value? lastModified, + Value? hasPreview, + Value? isFavorite, + Value? fileMetadataWidth, + Value? fileMetadataHeight}) { return NcAlbumItemsCompanion( rowId: rowId ?? this.rowId, parent: parent ?? this.parent, + relativePath: relativePath ?? this.relativePath, fileId: fileId ?? this.fileId, + contentLength: contentLength ?? this.contentLength, + contentType: contentType ?? this.contentType, + etag: etag ?? this.etag, + lastModified: lastModified ?? this.lastModified, + hasPreview: hasPreview ?? this.hasPreview, + isFavorite: isFavorite ?? this.isFavorite, + fileMetadataWidth: fileMetadataWidth ?? this.fileMetadataWidth, + fileMetadataHeight: fileMetadataHeight ?? this.fileMetadataHeight, ); } @@ -4700,9 +4971,38 @@ class NcAlbumItemsCompanion extends UpdateCompanion { if (parent.present) { map['parent'] = Variable(parent.value); } + if (relativePath.present) { + map['relative_path'] = Variable(relativePath.value); + } if (fileId.present) { map['file_id'] = Variable(fileId.value); } + if (contentLength.present) { + map['content_length'] = Variable(contentLength.value); + } + if (contentType.present) { + map['content_type'] = Variable(contentType.value); + } + if (etag.present) { + map['etag'] = Variable(etag.value); + } + if (lastModified.present) { + final converter = $NcAlbumItemsTable.$converter0; + map['last_modified'] = + Variable(converter.mapToSql(lastModified.value)); + } + if (hasPreview.present) { + map['has_preview'] = Variable(hasPreview.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (fileMetadataWidth.present) { + map['file_metadata_width'] = Variable(fileMetadataWidth.value); + } + if (fileMetadataHeight.present) { + map['file_metadata_height'] = Variable(fileMetadataHeight.value); + } return map; } @@ -4711,7 +5011,16 @@ class NcAlbumItemsCompanion extends UpdateCompanion { return (StringBuffer('NcAlbumItemsCompanion(') ..write('rowId: $rowId, ') ..write('parent: $parent, ') - ..write('fileId: $fileId') + ..write('relativePath: $relativePath, ') + ..write('fileId: $fileId, ') + ..write('contentLength: $contentLength, ') + ..write('contentType: $contentType, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('hasPreview: $hasPreview, ') + ..write('isFavorite: $isFavorite, ') + ..write('fileMetadataWidth: $fileMetadataWidth, ') + ..write('fileMetadataHeight: $fileMetadataHeight') ..write(')')) .toString(); } @@ -4737,13 +5046,83 @@ class $NcAlbumItemsTable extends NcAlbumItems type: const IntType(), requiredDuringInsert: true, defaultConstraints: 'REFERENCES nc_albums (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 _fileIdMeta = const VerificationMeta('fileId'); @override late final GeneratedColumn fileId = GeneratedColumn( 'file_id', aliasedName, false, type: const IntType(), requiredDuringInsert: true); + final VerificationMeta _contentLengthMeta = + const VerificationMeta('contentLength'); @override - List get $columns => [rowId, parent, fileId]; + late final GeneratedColumn contentLength = GeneratedColumn( + 'content_length', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _contentTypeMeta = + const VerificationMeta('contentType'); + @override + late final GeneratedColumn contentType = GeneratedColumn( + 'content_type', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _etagMeta = const VerificationMeta('etag'); + @override + late final GeneratedColumn etag = GeneratedColumn( + 'etag', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _lastModifiedMeta = + const VerificationMeta('lastModified'); + @override + late final GeneratedColumnWithTypeConverter + lastModified = GeneratedColumn( + 'last_modified', aliasedName, true, + type: const IntType(), requiredDuringInsert: false) + .withConverter($NcAlbumItemsTable.$converter0); + final VerificationMeta _hasPreviewMeta = const VerificationMeta('hasPreview'); + @override + late final GeneratedColumn hasPreview = GeneratedColumn( + 'has_preview', aliasedName, true, + type: const BoolType(), + requiredDuringInsert: false, + defaultConstraints: 'CHECK (has_preview IN (0, 1))'); + final VerificationMeta _isFavoriteMeta = const VerificationMeta('isFavorite'); + @override + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, true, + type: const BoolType(), + requiredDuringInsert: false, + defaultConstraints: 'CHECK (is_favorite IN (0, 1))'); + final VerificationMeta _fileMetadataWidthMeta = + const VerificationMeta('fileMetadataWidth'); + @override + late final GeneratedColumn fileMetadataWidth = GeneratedColumn( + 'file_metadata_width', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _fileMetadataHeightMeta = + const VerificationMeta('fileMetadataHeight'); + @override + late final GeneratedColumn fileMetadataHeight = GeneratedColumn( + 'file_metadata_height', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + @override + List get $columns => [ + rowId, + parent, + relativePath, + fileId, + contentLength, + contentType, + etag, + lastModified, + hasPreview, + isFavorite, + fileMetadataWidth, + fileMetadataHeight + ]; @override String get aliasedName => _alias ?? 'nc_album_items'; @override @@ -4763,12 +5142,61 @@ class $NcAlbumItemsTable extends NcAlbumItems } else if (isInserting) { context.missing(_parentMeta); } + if (data.containsKey('relative_path')) { + context.handle( + _relativePathMeta, + relativePath.isAcceptableOrUnknown( + data['relative_path']!, _relativePathMeta)); + } else if (isInserting) { + context.missing(_relativePathMeta); + } if (data.containsKey('file_id')) { context.handle(_fileIdMeta, fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta)); } else if (isInserting) { context.missing(_fileIdMeta); } + if (data.containsKey('content_length')) { + context.handle( + _contentLengthMeta, + contentLength.isAcceptableOrUnknown( + data['content_length']!, _contentLengthMeta)); + } + if (data.containsKey('content_type')) { + context.handle( + _contentTypeMeta, + contentType.isAcceptableOrUnknown( + data['content_type']!, _contentTypeMeta)); + } + if (data.containsKey('etag')) { + context.handle( + _etagMeta, etag.isAcceptableOrUnknown(data['etag']!, _etagMeta)); + } + context.handle(_lastModifiedMeta, const VerificationResult.success()); + if (data.containsKey('has_preview')) { + context.handle( + _hasPreviewMeta, + hasPreview.isAcceptableOrUnknown( + data['has_preview']!, _hasPreviewMeta)); + } + if (data.containsKey('is_favorite')) { + context.handle( + _isFavoriteMeta, + isFavorite.isAcceptableOrUnknown( + data['is_favorite']!, _isFavoriteMeta)); + } + if (data.containsKey('file_metadata_width')) { + context.handle( + _fileMetadataWidthMeta, + fileMetadataWidth.isAcceptableOrUnknown( + data['file_metadata_width']!, _fileMetadataWidthMeta)); + } + if (data.containsKey('file_metadata_height')) { + context.handle( + _fileMetadataHeightMeta, + fileMetadataHeight.isAcceptableOrUnknown( + data['file_metadata_height']!, _fileMetadataHeightMeta)); + } return context; } @@ -4788,6 +5216,9 @@ class $NcAlbumItemsTable extends NcAlbumItems $NcAlbumItemsTable createAlias(String alias) { return $NcAlbumItemsTable(attachedDatabase, alias); } + + static TypeConverter $converter0 = + const SqliteDateTimeConverter(); } abstract class _$SqliteDb extends GeneratedDatabase { diff --git a/app/lib/entity/sqlite/table.dart b/app/lib/entity/sqlite/table.dart index 965fb018..71ee6dcd 100644 --- a/app/lib/entity/sqlite/table.dart +++ b/app/lib/entity/sqlite/table.dart @@ -137,6 +137,7 @@ class NcAlbums extends Table { dateTime().map(const SqliteDateTimeConverter()).nullable()(); DateTimeColumn get dateEnd => dateTime().map(const SqliteDateTimeConverter()).nullable()(); + TextColumn get collaborators => text()(); @override List>? get uniqueKeys => [ @@ -148,7 +149,17 @@ class NcAlbumItems extends Table { IntColumn get rowId => integer().autoIncrement()(); IntColumn get parent => integer().references(NcAlbums, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get relativePath => text()(); IntColumn get fileId => integer()(); + IntColumn get contentLength => integer().nullable()(); + TextColumn get contentType => text().nullable()(); + TextColumn get etag => text().nullable()(); + DateTimeColumn get lastModified => + dateTime().map(const SqliteDateTimeConverter()).nullable()(); + BoolColumn get hasPreview => boolean().nullable()(); + BoolColumn get isFavorite => boolean().nullable()(); + IntColumn get fileMetadataWidth => integer().nullable()(); + IntColumn get fileMetadataHeight => integer().nullable()(); @override List>? get uniqueKeys => [ diff --git a/app/lib/entity/sqlite/type_converter.dart b/app/lib/entity/sqlite/type_converter.dart index c772238d..438f2ec1 100644 --- a/app/lib/entity/sqlite/type_converter.dart +++ b/app/lib/entity/sqlite/type_converter.dart @@ -9,6 +9,7 @@ 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/nc_album_item.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'; @@ -254,15 +255,21 @@ 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 NcAlbum fromSql(String userId, sql.NcAlbum ncAlbum) { + final json = ncAlbum.collaborators + .run((obj) => (jsonDecode(obj) as List).cast()); + return 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: json + .map((e) => NcAlbumCollaborator.fromJson(e.cast())) + .toList(), + ); + } static sql.NcAlbumsCompanion toSql(sql.Account? dbAccount, NcAlbum ncAlbum) => sql.NcAlbumsCompanion( @@ -274,19 +281,44 @@ class SqliteNcAlbumConverter { location: Value(ncAlbum.location), dateStart: Value(ncAlbum.dateStart), dateEnd: Value(ncAlbum.dateEnd), + collaborators: Value( + jsonEncode(ncAlbum.collaborators.map((c) => c.toJson()).toList())), ); } class SqliteNcAlbumItemConverter { - static int fromSql(sql.NcAlbumItem item) => item.fileId; + static NcAlbumItem fromSql( + String userId, String albumRelativePath, sql.NcAlbumItem item) => + NcAlbumItem( + path: + "remote.php/dav/photos/$userId/albums/$albumRelativePath/${item.relativePath}", + fileId: item.fileId, + contentLength: item.contentLength, + contentType: item.contentType, + etag: item.etag, + lastModified: item.lastModified, + hasPreview: item.hasPreview, + isFavorite: item.isFavorite, + fileMetadataWidth: item.fileMetadataWidth, + fileMetadataHeight: item.fileMetadataHeight, + ); static sql.NcAlbumItemsCompanion toSql( sql.NcAlbum parent, - int fileId, + NcAlbumItem item, ) => sql.NcAlbumItemsCompanion( parent: Value(parent.rowId), - fileId: Value(fileId), + relativePath: Value(item.strippedPath), + fileId: Value(item.fileId), + contentLength: Value(item.contentLength), + contentType: Value(item.contentType), + etag: Value(item.etag), + lastModified: Value(item.lastModified), + hasPreview: Value(item.hasPreview), + isFavorite: Value(item.isFavorite), + fileMetadataWidth: Value(item.fileMetadataWidth), + fileMetadataHeight: Value(item.fileMetadataHeight), ); } 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 index 43f6de21..df494ca7 100644 --- a/app/lib/use_case/nc_album/list_nc_album_item.dart +++ b/app/lib/use_case/nc_album/list_nc_album_item.dart @@ -1,7 +1,7 @@ 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'; +import 'package:nc_photos/entity/nc_album_item.dart'; class ListNcAlbumItem { ListNcAlbumItem(this._c) : assert(require(_c)); diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 050f0a10..fc12b990 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -25,6 +25,7 @@ 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/nc_album_item_adapter.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'; diff --git a/app/lib/widget/collection_browser/type.dart b/app/lib/widget/collection_browser/type.dart index 0706dd2d..bc166cea 100644 --- a/app/lib/widget/collection_browser/type.dart +++ b/app/lib/widget/collection_browser/type.dart @@ -47,7 +47,8 @@ class _PhotoItem extends _FileItem { required super.original, required super.file, required this.account, - }) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file); + }) : _previewUrl = _getCollectionFilePreviewUrl( + account, original as CollectionFileItem); @override StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); @@ -75,7 +76,8 @@ class _VideoItem extends _FileItem { required super.original, required super.file, required this.account, - }) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file); + }) : _previewUrl = _getCollectionFilePreviewUrl( + account, original as CollectionFileItem); @override StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); @@ -174,3 +176,13 @@ class _DateItem extends _Item { final DateTime date; } + +String _getCollectionFilePreviewUrl(Account account, CollectionFileItem item) { + if (item is CollectionFileItemNcAlbumItemAdapter) { + return item.localFile == null + ? NetworkRectThumbnail.imageUrlForNcAlbumFile(account, item.item) + : NetworkRectThumbnail.imageUrlForFile(account, item.file); + } else { + return NetworkRectThumbnail.imageUrlForFile(account, item.file); + } +} diff --git a/app/lib/widget/network_thumbnail.dart b/app/lib/widget/network_thumbnail.dart index 50e5a413..40ee9113 100644 --- a/app/lib/widget/network_thumbnail.dart +++ b/app/lib/widget/network_thumbnail.dart @@ -5,6 +5,7 @@ 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_descriptor.dart'; +import 'package:nc_photos/entity/nc_album_item.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; @@ -36,6 +37,14 @@ class NetworkRectThumbnail extends StatelessWidget { isKeepAspectRatio: true, ); + static String imageUrlForNcAlbumFile(Account account, NcAlbumItem item) => + api_util.getNcAlbumFilePreviewUrl( + account, + item, + width: k.photoThumbSize, + height: k.photoThumbSize, + ); + @override Widget build(BuildContext context) { final child = FittedBox( diff --git a/np_api/lib/np_api.dart b/np_api/lib/np_api.dart index e8d9a56e..5354a393 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_item_parser.dart'; export 'src/entity/nc_album_parser.dart'; export 'src/entity/person_parser.dart'; export 'src/entity/share_parser.dart'; diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index 1f4071d5..90a484f2 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -111,6 +111,55 @@ class NcAlbum with EquatableMixin { required this.nbItems, required this.location, required this.dateRange, + required this.collaborators, + }); + + @override + String toString() => _$toString(); + + @override + List get props => + [href, lastPhoto, nbItems, location, dateRange, collaborators]; + + final String href; + final int? lastPhoto; + final int? nbItems; + final String? location; + final JsonObj? dateRange; + final List collaborators; +} + +@toString +class NcAlbumCollaborator with EquatableMixin { + const NcAlbumCollaborator({ + required this.id, + required this.label, + required this.type, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [id, label, type]; + + final String id; + final String label; + final int type; +} + +@ToString(ignoreNull: true) +class NcAlbumItem with EquatableMixin { + const NcAlbumItem({ + required this.href, + this.fileId, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.hasPreview, + this.favorite, + this.fileMetadataSize, }); @override @@ -119,17 +168,25 @@ class NcAlbum with EquatableMixin { @override List get props => [ href, - lastPhoto, - nbItems, - location, - dateRange, + fileId, + contentLength, + contentType, + etag, + lastModified, + hasPreview, + favorite, + fileMetadataSize, ]; final String href; - final int? lastPhoto; - final int? nbItems; - final String? location; - final JsonObj? dateRange; + final int? fileId; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final bool? hasPreview; + final bool? favorite; + final JsonObj? fileMetadataSize; } @toString diff --git a/np_api/lib/src/entity/entity.g.dart b/np_api/lib/src/entity/entity.g.dart index dff92e0d..fcf821e8 100644 --- a/np_api/lib/src/entity/entity.g.dart +++ b/np_api/lib/src/entity/entity.g.dart @@ -30,7 +30,21 @@ 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}"; + return "NcAlbum {href: $href, lastPhoto: $lastPhoto, nbItems: $nbItems, location: $location, dateRange: $dateRange, collaborators: $collaborators}"; + } +} + +extension _$NcAlbumCollaboratorToString on NcAlbumCollaborator { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NcAlbumCollaborator {id: $id, label: $label, type: $type}"; + } +} + +extension _$NcAlbumItemToString on NcAlbumItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NcAlbumItem {href: $href, ${fileId == null ? "" : "fileId: $fileId, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${contentType == null ? "" : "contentType: $contentType, "}${etag == null ? "" : "etag: $etag, "}${lastModified == null ? "" : "lastModified: $lastModified, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${favorite == null ? "" : "favorite: $favorite, "}${fileMetadataSize == null ? "" : "fileMetadataSize: $fileMetadataSize"}}"; } } diff --git a/np_api/lib/src/entity/nc_album_item_parser.dart b/np_api/lib/src/entity/nc_album_item_parser.dart new file mode 100644 index 00000000..5873538d --- /dev/null +++ b/np_api/lib/src/entity/nc_album_item_parser.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'dart:io'; + +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 NcAlbumItemParser extends XmlResponseParser { + Future> parse(String response) => + compute(_parseNcAlbumItemsIsolate, response); + + List _parse(XmlDocument xml) => + parseT(xml, _toNcAlbumItem); + + /// Map contents to NcAlbumItem + NcAlbumItem _toNcAlbumItem(XmlElement element) { + String? href; + int? fileId; + int? contentLength; + String? contentType; + String? etag; + DateTime? lastModified; + bool? hasPreview; + bool? favorite; + JsonObj? fileMetadataSize; + // unclear what the value types are + // "nc:face-detections" + // "nc:realpath" + // "oc:permissions" + + 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); + fileId = propParser.fileId; + contentLength = propParser.contentLength; + contentType = propParser.contentType; + etag = propParser.etag; + lastModified = propParser.lastModified; + hasPreview = propParser.hasPreview; + favorite = propParser.favorite; + fileMetadataSize = propParser.fileMetadataSize; + } + } + + return NcAlbumItem( + href: href!, + fileId: fileId, + contentLength: contentLength, + contentType: contentType, + etag: etag, + lastModified: lastModified, + hasPreview: hasPreview, + favorite: favorite, + fileMetadataSize: fileMetadataSize, + ); + } +} + +class _PropParser { + _PropParser({ + this.namespaces = const {}, + }); + + /// Parse element contents + void parse(XmlElement element) { + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("getlastmodified", + prefix: "DAV:", namespaces: namespaces)) { + _lastModified = HttpDate.parse(child.innerText); + } else if (child.matchQualifiedName("getetag", + prefix: "DAV:", namespaces: namespaces)) { + _etag = child.innerText.replaceAll("\"", ""); + } else if (child.matchQualifiedName("getcontenttype", + prefix: "DAV:", namespaces: namespaces)) { + _contentType = child.innerText; + } else if (child.matchQualifiedName("getcontentlength", + prefix: "DAV:", namespaces: namespaces)) { + _contentLength = int.parse(child.innerText); + } else if (child.matchQualifiedName("fileid", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _fileId = int.parse(child.innerText); + } else if (child.matchQualifiedName("favorite", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _favorite = child.innerText != "0"; + } else if (child.matchQualifiedName("has-preview", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _hasPreview = child.innerText == "true"; + } else if (child.matchQualifiedName("file-metadata-size", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _fileMetadataSize = + child.innerText.isEmpty ? null : jsonDecode(child.innerText); + } + } + } + + DateTime? get lastModified => _lastModified; + String? get etag => _etag; + String? get contentType => _contentType; + int? get contentLength => _contentLength; + int? get fileId => _fileId; + bool? get favorite => _favorite; + bool? get hasPreview => _hasPreview; + JsonObj? get fileMetadataSize => _fileMetadataSize; + + final Map namespaces; + + DateTime? _lastModified; + String? _etag; + String? _contentType; + int? _contentLength; + int? _fileId; + bool? _favorite; + bool? _hasPreview; + JsonObj? _fileMetadataSize; +} + +List _parseNcAlbumItemsIsolate(String response) { + initLog(); + final xml = XmlDocument.parse(response); + return NcAlbumItemParser()._parse(xml); +} diff --git a/np_api/lib/src/entity/nc_album_parser.dart b/np_api/lib/src/entity/nc_album_parser.dart index fd890f7e..3fc8b3d6 100644 --- a/np_api/lib/src/entity/nc_album_parser.dart +++ b/np_api/lib/src/entity/nc_album_parser.dart @@ -20,6 +20,7 @@ class NcAlbumParser extends XmlResponseParser { int? nbItems; String? location; JsonObj? dateRange; + List? collaborators; for (final child in element.children.whereType()) { if (child.matchQualifiedName("href", @@ -44,6 +45,7 @@ class NcAlbumParser extends XmlResponseParser { nbItems = propParser.nbItems; location = propParser.location; dateRange = propParser.dateRange; + collaborators = propParser.collaborators; } } @@ -53,6 +55,7 @@ class NcAlbumParser extends XmlResponseParser { nbItems: nbItems, location: location, dateRange: dateRange, + collaborators: collaborators ?? const [], ); } } @@ -79,14 +82,44 @@ class _PropParser { prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { _dateRange = child.innerText.isEmpty ? null : jsonDecode(child.innerText); + } else if (child.matchQualifiedName("collaborators", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + for (final cc in child.children.whereType()) { + if (cc.matchQualifiedName("collaborator", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _collaborators ??= []; + _collaborators!.add(_parseCollaborator(cc)); + } + } } } } + NcAlbumCollaborator _parseCollaborator(XmlElement element) { + late String id; + late String label; + late int type; + for (final child in element.children.whereType()) { + switch (child.localName) { + case "id": + id = child.innerText; + break; + case "label": + label = child.innerText; + break; + case "type": + type = int.parse(child.innerText); + break; + } + } + return NcAlbumCollaborator(id: id, label: label, type: type); + } + int? get lastPhoto => _lastPhoto; int? get nbItems => _nbItems; String? get location => _location; JsonObj? get dateRange => _dateRange; + List? get collaborators => _collaborators; final Map namespaces; @@ -94,6 +127,7 @@ class _PropParser { int? _nbItems; String? _location; JsonObj? _dateRange; + List? _collaborators; } List _parseNcAlbumsIsolate(String response) { diff --git a/np_api/test/entity/nc_album_parser_test.dart b/np_api/test/entity/nc_album_parser_test.dart new file mode 100644 index 00000000..f299b8bc --- /dev/null +++ b/np_api/test/entity/nc_album_parser_test.dart @@ -0,0 +1,247 @@ +import 'package:np_api/np_api.dart'; +import 'package:test/test.dart'; + +void main() { + group("NcAlbumParser", () { + test("no album", _noAlbum); + test("empty", _empty); + test("basic", _basic); + test("collaborative", _collaborative); + }); +} + +Future _noAlbum() async { + const xml = """ + + + + /remote.php/dav/photos/admin/albums/ + + + + + + + + + HTTP/1.1 404 Not Found + + + +"""; + final results = await NcAlbumParser().parse(xml); + expect( + results, + [ + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/", + lastPhoto: null, + nbItems: null, + location: null, + dateRange: null, + collaborators: [], + ), + ], + ); +} + +Future _empty() async { + const xml = """ + + + + /remote.php/dav/photos/admin/albums/ + + + + + + + + + HTTP/1.1 404 Not Found + + + + /remote.php/dav/photos/admin/albums/test/ + + + -1 + 0 + + {"start":null,"end":null} + + + HTTP/1.1 200 OK + + + +"""; + final results = await NcAlbumParser().parse(xml); + expect( + results, + [ + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/", + lastPhoto: null, + nbItems: null, + location: null, + dateRange: null, + collaborators: [], + ), + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/test/", + lastPhoto: -1, + nbItems: 0, + location: null, + dateRange: { + "start": null, + "end": null, + }, + collaborators: [], + ), + ], + ); +} + +Future _basic() async { + const xml = """ + + + + /remote.php/dav/photos/admin/albums/ + + + + + + + + + HTTP/1.1 404 Not Found + + + + /remote.php/dav/photos/admin/albums/test/ + + + 1 + 1 + + {"start":1577934245,"end":1580702706} + + + HTTP/1.1 200 OK + + + +"""; + final results = await NcAlbumParser().parse(xml); + expect( + results, + [ + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/", + lastPhoto: null, + nbItems: null, + location: null, + dateRange: null, + collaborators: [], + ), + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/test/", + lastPhoto: 1, + nbItems: 1, + location: null, + dateRange: { + "start": 1577934245, + "end": 1580702706, + }, + collaborators: [], + ), + ], + ); +} + +Future _collaborative() async { + const xml = """ + + + + /remote.php/dav/photos/admin/albums/ + + + + + + + + + HTTP/1.1 404 Not Found + + + + /remote.php/dav/photos/admin/albums/test/ + + + 1 + 1 + + {"start":1577934245,"end":1580702706} + + + user2 + + 0 + + + + HTTP/1.1 200 OK + + + +"""; + final results = await NcAlbumParser().parse(xml); + expect( + results, + [ + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/", + lastPhoto: null, + nbItems: null, + location: null, + dateRange: null, + collaborators: [], + ), + const NcAlbum( + href: "/remote.php/dav/photos/admin/albums/test/", + lastPhoto: 1, + nbItems: 1, + location: null, + dateRange: { + "start": 1577934245, + "end": 1580702706, + }, + collaborators: [ + NcAlbumCollaborator( + id: "user2", + label: "User2", + type: 0, + ), + ], + ), + ], + ); +} From 067b6a1a609d3d2054f35ecfe49701c22d04716b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 7 May 2023 22:00:33 +0800 Subject: [PATCH 29/67] Fix shared image in collaborative album not showing in viewer --- app/lib/api/api_util.dart | 18 +++++------------- app/lib/entity/file_util.dart | 4 ++++ app/lib/entity/nc_album.dart | 3 ++- app/lib/entity/nc_album_item.dart | 5 +++-- app/lib/entity/sqlite/type_converter.dart | 5 +++-- app/lib/widget/collection_browser/type.dart | 16 ++-------------- app/lib/widget/network_thumbnail.dart | 9 --------- np_api/lib/src/photos_api.dart | 10 ++++++---- 8 files changed, 25 insertions(+), 45 deletions(-) diff --git a/app/lib/api/api_util.dart b/app/lib/api/api_util.dart index 59988002..82ca3a7e 100644 --- a/app/lib/api/api_util.dart +++ b/app/lib/api/api_util.dart @@ -7,7 +7,6 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.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/nc_album_item.dart'; import 'package:nc_photos/exception.dart'; import 'package:np_api/np_api.dart' hide NcAlbumItem; import 'package:to_string/to_string.dart'; @@ -46,6 +45,11 @@ String getFilePreviewUrlRelative( if (file_util.isTrash(account, file)) { // trashbin does not support preview.png endpoint url = "index.php/apps/files_trashbin/preview?fileId=${file.fdId}"; + } else if (file_util.isNcAlbumFile(account, file)) { + // We can't use the generic file preview url because collaborative albums do + // not create a file share for photos not belonging to you, that means you + // can only access the file view the Photos API + url = "apps/photos/api/v1/preview/${file.fdId}?x=$width&y=$height"; } else { url = "index.php/core/preview?fileId=${file.fdId}"; } @@ -77,18 +81,6 @@ String getFilePreviewUrlByFileId( return url; } -/// Return the preview image URL for an item in [NcAlbum]. We can't use the -/// generic file preview url because collaborative albums do not create a file -/// share for photos not belonging to you, that means you can only access the -/// file view the Photos API -String getNcAlbumFilePreviewUrl( - Account account, - NcAlbumItem item, { - required int width, - required int height, -}) => - "${account.url}/apps/photos/api/v1/preview/${item.fileId}?x=$width&y=$height"; - String getFileUrl(Account account, FileDescriptor file) { return "${account.url}/${getFileUrlRelative(file)}"; } diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 3551803e..62ca345d 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.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:np_api/np_api.dart' as api; import 'package:np_common/ci_string.dart'; import 'package:np_common/string_extension.dart'; import 'package:path/path.dart' as path_lib; @@ -38,6 +39,9 @@ bool isTrash(Account account, FileDescriptor file) => bool isAlbumFile(Account account, FileDescriptor file) => file.fdPath.startsWith(remote_storage_util.getRemoteAlbumsDir(account)); +bool isNcAlbumFile(Account account, FileDescriptor file) => + file.fdPath.startsWith("${api.ApiPhotos.path}/"); + /// Return if [file] is located under [dir] /// /// Return false if [file] is [dir] itself (since it's not "under") diff --git a/app/lib/entity/nc_album.dart b/app/lib/entity/nc_album.dart index b54492de..8cc0ff0f 100644 --- a/app/lib/entity/nc_album.dart +++ b/app/lib/entity/nc_album.dart @@ -1,6 +1,7 @@ import 'package:copy_with/copy_with.dart'; import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; +import 'package:np_api/np_api.dart' as api; import 'package:np_common/ci_string.dart'; import 'package:np_common/string_extension.dart'; import 'package:np_common/type.dart'; @@ -27,7 +28,7 @@ class NcAlbum with EquatableMixin { required String name, }) { return NcAlbum( - path: "remote.php/dav/photos/${account.userId}/albums/$name", + path: "${api.ApiPhotos.path}/${account.userId}/albums/$name", lastPhoto: null, nbItems: 0, location: null, diff --git a/app/lib/entity/nc_album_item.dart b/app/lib/entity/nc_album_item.dart index 1ef40c93..a16053a9 100644 --- a/app/lib/entity/nc_album_item.dart +++ b/app/lib/entity/nc_album_item.dart @@ -1,4 +1,5 @@ import 'package:nc_photos/entity/file.dart'; +import 'package:np_api/np_api.dart' as api; import 'package:np_common/string_extension.dart'; import 'package:to_string/to_string.dart'; @@ -40,10 +41,10 @@ extension NcAlbumItemExtension on NcAlbumItem { /// WebDAV file path: remote.php/dav/photos/{userId}/albums/{album}/{strippedPath}. /// If this path points to the user's root album path, return "." String get strippedPath { - if (!path.startsWith("remote.php/dav/photos/")) { + if (!path.startsWith("${api.ApiPhotos.path}/")) { return path; } - var begin = "remote.php/dav/photos/".length; + var begin = "${api.ApiPhotos.path}/".length; begin = path.indexOf("/", begin); if (begin == -1) { return path; diff --git a/app/lib/entity/sqlite/type_converter.dart b/app/lib/entity/sqlite/type_converter.dart index 438f2ec1..0974ae1f 100644 --- a/app/lib/entity/sqlite/type_converter.dart +++ b/app/lib/entity/sqlite/type_converter.dart @@ -16,6 +16,7 @@ import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:np_api/np_api.dart' as api; import 'package:np_common/ci_string.dart'; extension SqlTagListExtension on List { @@ -259,7 +260,7 @@ class SqliteNcAlbumConverter { final json = ncAlbum.collaborators .run((obj) => (jsonDecode(obj) as List).cast()); return NcAlbum( - path: "remote.php/dav/photos/$userId/albums/${ncAlbum.relativePath}", + path: "${api.ApiPhotos.path}/$userId/albums/${ncAlbum.relativePath}", lastPhoto: ncAlbum.lastPhoto, nbItems: ncAlbum.nbItems, location: ncAlbum.location, @@ -291,7 +292,7 @@ class SqliteNcAlbumItemConverter { String userId, String albumRelativePath, sql.NcAlbumItem item) => NcAlbumItem( path: - "remote.php/dav/photos/$userId/albums/$albumRelativePath/${item.relativePath}", + "${api.ApiPhotos.path}/$userId/albums/$albumRelativePath/${item.relativePath}", fileId: item.fileId, contentLength: item.contentLength, contentType: item.contentType, diff --git a/app/lib/widget/collection_browser/type.dart b/app/lib/widget/collection_browser/type.dart index bc166cea..0706dd2d 100644 --- a/app/lib/widget/collection_browser/type.dart +++ b/app/lib/widget/collection_browser/type.dart @@ -47,8 +47,7 @@ class _PhotoItem extends _FileItem { required super.original, required super.file, required this.account, - }) : _previewUrl = _getCollectionFilePreviewUrl( - account, original as CollectionFileItem); + }) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file); @override StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); @@ -76,8 +75,7 @@ class _VideoItem extends _FileItem { required super.original, required super.file, required this.account, - }) : _previewUrl = _getCollectionFilePreviewUrl( - account, original as CollectionFileItem); + }) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file); @override StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); @@ -176,13 +174,3 @@ class _DateItem extends _Item { final DateTime date; } - -String _getCollectionFilePreviewUrl(Account account, CollectionFileItem item) { - if (item is CollectionFileItemNcAlbumItemAdapter) { - return item.localFile == null - ? NetworkRectThumbnail.imageUrlForNcAlbumFile(account, item.item) - : NetworkRectThumbnail.imageUrlForFile(account, item.file); - } else { - return NetworkRectThumbnail.imageUrlForFile(account, item.file); - } -} diff --git a/app/lib/widget/network_thumbnail.dart b/app/lib/widget/network_thumbnail.dart index 40ee9113..50e5a413 100644 --- a/app/lib/widget/network_thumbnail.dart +++ b/app/lib/widget/network_thumbnail.dart @@ -5,7 +5,6 @@ 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_descriptor.dart'; -import 'package:nc_photos/entity/nc_album_item.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; @@ -37,14 +36,6 @@ class NetworkRectThumbnail extends StatelessWidget { isKeepAspectRatio: true, ); - static String imageUrlForNcAlbumFile(Account account, NcAlbumItem item) => - api_util.getNcAlbumFilePreviewUrl( - account, - item, - width: k.photoThumbSize, - height: k.photoThumbSize, - ); - @override Widget build(BuildContext context) { final child = FittedBox( diff --git a/np_api/lib/src/photos_api.dart b/np_api/lib/src/photos_api.dart index f2bd51b8..def78327 100644 --- a/np_api/lib/src/photos_api.dart +++ b/np_api/lib/src/photos_api.dart @@ -6,6 +6,8 @@ class ApiPhotos { ApiPhotosAlbums albums() => ApiPhotosAlbums(this); ApiPhotosAlbum album(String name) => ApiPhotosAlbum(this, name); + static String get path => "remote.php/dav/photos"; + final Api api; final String userId; } @@ -22,7 +24,7 @@ class ApiPhotosAlbums { dateRange, collaborators, }) async { - final endpoint = "remote.php/dav/photos/${photos.userId}/albums"; + final endpoint = "${ApiPhotos.path}/${photos.userId}/albums"; try { if (lastPhoto == null && nbItems == null && @@ -96,7 +98,7 @@ class ApiPhotosAlbum { fileid, permissions, }) async { - final endpoint = "remote.php/dav/photos/${photos.userId}/albums/$albumId"; + final endpoint = "${ApiPhotos.path}/${photos.userId}/albums/$albumId"; try { final bool hasDavNs = (getcontentlength != null || getcontenttype != null || @@ -178,7 +180,7 @@ class ApiPhotosAlbum { Future mkcol() async { try { - final endpoint = "remote.php/dav/photos/${photos.userId}/albums/$albumId"; + final endpoint = "${ApiPhotos.path}/${photos.userId}/albums/$albumId"; return await api.request("MKCOL", endpoint); } catch (e) { _log.severe("[mkcol] Failed while MKCOL", e); @@ -188,7 +190,7 @@ class ApiPhotosAlbum { Future delete() async { try { - final endpoint = "remote.php/dav/photos/${photos.userId}/albums/$albumId"; + final endpoint = "${ApiPhotos.path}/${photos.userId}/albums/$albumId"; return await api.request("DELETE", endpoint); } catch (e) { _log.severe("[delete] Failed while DELETE", e); From 4762ad02bda2910a30919d9c4fd7930fdda1dcc9 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 7 May 2023 23:12:40 +0800 Subject: [PATCH 30/67] Fix error in bloc after closed causing stack overflow --- app/lib/widget/share_collection_dialog/bloc.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/lib/widget/share_collection_dialog/bloc.dart b/app/lib/widget/share_collection_dialog/bloc.dart index 56ad2185..47585d3f 100644 --- a/app/lib/widget/share_collection_dialog/bloc.dart +++ b/app/lib/widget/share_collection_dialog/bloc.dart @@ -60,7 +60,14 @@ class _Bloc extends Bloc<_Event, _State> { @override void onError(Object error, StackTrace stackTrace) { - add(_SetError(error, stackTrace)); + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } super.onError(error, stackTrace); } @@ -144,4 +151,5 @@ class _Bloc extends Bloc<_Event, _State> { final CollectionsController collectionsController; StreamSubscription? _collectionControllerSubscription; + var _isHandlingError = false; } From 6b3600625b4593fd83d398209eb108e32ff8c3e2 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 8 May 2023 01:33:11 +0800 Subject: [PATCH 31/67] Fix cover not displaying for NcAlbum if the file is shared --- app/lib/api/api_util.dart | 10 ++++++++++ .../entity/collection/content_provider/nc_album.dart | 3 +-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/lib/api/api_util.dart b/app/lib/api/api_util.dart index 82ca3a7e..b2e8f22b 100644 --- a/app/lib/api/api_util.dart +++ b/app/lib/api/api_util.dart @@ -81,6 +81,16 @@ String getFilePreviewUrlByFileId( return url; } +/// Return the preview image URL for [fileId], using the new Photos API in +/// Nextcloud 25 +String getPhotosApiFilePreviewUrlByFileId( + Account account, + int fileId, { + required int width, + required int height, +}) => + "${account.url}/apps/photos/api/v1/preview/$fileId?x=$width&y=$height"; + String getFileUrl(Account account, FileDescriptor file) { return "${account.url}/${getFileUrlRelative(file)}"; } diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index 044e9c00..609446e3 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -64,12 +64,11 @@ class CollectionNcAlbumProvider if (album.lastPhoto == null) { return null; } else { - return api_util.getFilePreviewUrlByFileId( + return api_util.getPhotosApiFilePreviewUrlByFileId( account, album.lastPhoto!, width: width, height: height, - isKeepAspectRatio: isKeepAspectRatio ?? false, ); } } From 499583d76fdd43a5f46996d6299bd4027c7de81e Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 10 May 2023 22:47:15 +0800 Subject: [PATCH 32/67] Impl pull to refresh in HomeCollections --- .../controller/collections_controller.dart | 30 +++- app/lib/widget/home_collections.dart | 165 ++++++++++-------- app/lib/widget/home_collections.g.dart | 7 + app/lib/widget/home_collections/bloc.dart | 6 + .../widget/home_collections/state_event.dart | 8 + 5 files changed, 133 insertions(+), 83 deletions(-) diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 5777d4a5..41f4467f 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -91,6 +91,27 @@ class CollectionsController { /// Peek the stream and return the current value CollectionStreamEvent peekStream() => _dataStreamController.stream.value; + /// Reload the data + /// + /// The data will be loaded automatically when the stream is first listened so + /// it's not necessary to call this method for that purpose + Future reload() async { + var results = []; + final completer = Completer(); + ListCollection(_c, serverController: serverController)(account).listen( + (c) { + results = c; + }, + onError: _dataStreamController.addError, + onDone: () => completer.complete(), + ); + await completer.future; + _dataStreamController.add(CollectionStreamEvent( + data: _prepareDataFor(results), + hasNext: false, + )); + } + 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 @@ -99,7 +120,7 @@ class CollectionsController { data: _prepareDataFor([ created, ...v.data.map((e) => e.collection), - ], shouldRemoveCache: false), + ]), )); return created; } @@ -246,7 +267,7 @@ class CollectionsController { ListCollection(_c)(account).listen( (c) { lastData = CollectionStreamEvent( - data: _prepareDataFor(c, shouldRemoveCache: true), + data: _prepareDataFor(c), hasNext: true, ); _dataStreamController.add(lastData); @@ -258,10 +279,7 @@ class CollectionsController { _dataStreamController.add(lastData.copyWith(hasNext: false)); } - List _prepareDataFor( - List collections, { - required bool shouldRemoveCache, - }) { + List _prepareDataFor(List collections) { final data = []; final keys = <_CollectionKey>[]; for (final c in collections) { diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 465d98ec..d5e8131e 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -123,91 +123,102 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> ], 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( + RefreshIndicator( + onRefresh: () async { + _bloc.add(const _ReloadCollections()); + await _bloc.stream.first; + }, + child: CustomScrollView( + slivers: [ + _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); - }, - ), + builder: (context, state) => state.selectedItems.isEmpty + ? const _AppBar() + : const _SelectionAppBar(), ), - ), - const SliverToBoxAdapter( - child: SizedBox(height: 8), - ), - _BlocBuilder( - buildWhen: (previous, current) => - previous.transformedItems != current.transformedItems || - previous.selectedItems != current.selectedItems, - builder: (context, state) => SliverPadding( + 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), - ); - }, + 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); + }, + ), ), ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: NavigationBarTheme.of(context).height, + 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, diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index 40b0b748..49d5cde3 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -107,6 +107,13 @@ extension _$_LoadCollectionsToString on _LoadCollections { } } +extension _$_ReloadCollectionsToString on _ReloadCollections { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ReloadCollections {}"; + } +} + extension _$_TransformItemsToString on _TransformItems { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/home_collections/bloc.dart b/app/lib/widget/home_collections/bloc.dart index f301bed6..15d63736 100644 --- a/app/lib/widget/home_collections/bloc.dart +++ b/app/lib/widget/home_collections/bloc.dart @@ -8,6 +8,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { required this.prefController, }) : super(_State.init()) { on<_LoadCollections>(_onLoad); + on<_ReloadCollections>(_onReload); on<_TransformItems>(_onTransformItems); on<_SetSelectedItems>(_onSetSelectedItems); @@ -49,6 +50,11 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { ); } + void _onReload(_ReloadCollections ev, Emitter<_State> emit) { + _log.info("[_onReload] $ev"); + unawaited(controller.reload()); + } + Future _onTransformItems( _TransformItems ev, Emitter<_State> emit) async { _log.info("[_onTransformItems] $ev"); diff --git a/app/lib/widget/home_collections/state_event.dart b/app/lib/widget/home_collections/state_event.dart index a2b94610..b9190909 100644 --- a/app/lib/widget/home_collections/state_event.dart +++ b/app/lib/widget/home_collections/state_event.dart @@ -50,6 +50,14 @@ class _LoadCollections implements _Event { String toString() => _$toString(); } +@toString +class _ReloadCollections implements _Event { + const _ReloadCollections(); + + @override + String toString() => _$toString(); +} + /// Transform the collection list (e.g., filtering, sorting, etc) @toString class _TransformItems implements _Event { From 2b177d76798202330054ac1285a24df18933363d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 11 May 2023 00:42:56 +0800 Subject: [PATCH 33/67] Don't try to query ncalbum when Nextcloud <25 --- app/lib/api/entity_converter.dart | 11 +++++ app/lib/bloc/home_search_suggestion.dart | 9 +++- app/lib/controller/account_controller.dart | 9 ++++ .../controller/collections_controller.dart | 5 +- app/lib/controller/server_controller.dart | 49 +++++++++++++++++++ app/lib/controller/server_controller.g.dart | 14 ++++++ app/lib/entity/server_status.dart | 24 +++++++++ app/lib/entity/server_status.g.dart | 14 ++++++ .../use_case/collection/list_collection.dart | 43 ++++++++++------ app/lib/widget/home.dart | 14 ++++++ app/lib/widget/home_search_suggestion.dart | 1 + np_api/lib/np_api.dart | 1 + np_api/lib/src/api.dart | 2 + np_api/lib/src/entity/entity.dart | 3 ++ np_api/lib/src/entity/status_parser.dart | 1 + np_api/test/entity/status_parser_test.dart | 34 +++++++++++++ 16 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 app/lib/controller/server_controller.dart create mode 100644 app/lib/controller/server_controller.g.dart create mode 100644 app/lib/entity/server_status.dart create mode 100644 app/lib/entity/server_status.g.dart create mode 100644 np_api/test/entity/status_parser_test.dart diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index 52b21522..cb83fce3 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -7,6 +7,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/nc_album.dart'; import 'package:nc_photos/entity/nc_album_item.dart'; import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/entity/server_status.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/entity/tag.dart'; @@ -179,6 +180,16 @@ class ApiShareeConverter { }; } +class ApiStatusConverter { + static ServerStatus fromApi(api.Status status) { + return ServerStatus( + versionRaw: status.version, + versionName: status.versionString, + productName: status.productName, + ); + } +} + class ApiTagConverter { static Tag fromApi(api.Tag tag) { return Tag( diff --git a/app/lib/bloc/home_search_suggestion.dart b/app/lib/bloc/home_search_suggestion.dart index 4792e86f..6575e447 100644 --- a/app/lib/bloc/home_search_suggestion.dart +++ b/app/lib/bloc/home_search_suggestion.dart @@ -5,6 +5,7 @@ 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/controller/server_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/person.dart'; @@ -126,7 +127,8 @@ class HomeSearchSuggestionBlocFailure extends HomeSearchSuggestionBlocState { @npLog class HomeSearchSuggestionBloc extends Bloc { - HomeSearchSuggestionBloc(this.account, this.collectionsController) + HomeSearchSuggestionBloc( + this.account, this.collectionsController, this.serverController) : super(const HomeSearchSuggestionBlocInit()) { final c = KiwiContainer().resolve(); assert(require(c)); @@ -194,7 +196,9 @@ class HomeSearchSuggestionBloc .map((e) => e.collection) .toList(); if (collections.isEmpty) { - collections = await ListCollection(_c)(account).last; + collections = await ListCollection(_c, + serverController: serverController)(account) + .last; } product.addAll(collections.map(_CollectionSearcheable.new)); _log.info( @@ -247,6 +251,7 @@ class HomeSearchSuggestionBloc final Account account; final CollectionsController collectionsController; + final ServerController serverController; late final DiContainer _c; final _search = Woozy<_Searcheable>(limit: 10); diff --git a/app/lib/controller/account_controller.dart b/app/lib/controller/account_controller.dart index bad7aa10..3470340d 100644 --- a/app/lib/controller/account_controller.dart +++ b/app/lib/controller/account_controller.dart @@ -1,6 +1,7 @@ import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/di_container.dart'; class AccountController { @@ -8,6 +9,7 @@ class AccountController { _account = account; _collectionsController?.dispose(); _collectionsController = null; + _serverController = null; } Account get account => _account!; @@ -16,8 +18,15 @@ class AccountController { _collectionsController ??= CollectionsController( KiwiContainer().resolve(), account: _account!, + serverController: serverController, + ); + + ServerController get serverController => + _serverController ??= ServerController( + account: _account!, ); Account? _account; CollectionsController? _collectionsController; + ServerController? _serverController; } diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 41f4467f..5bda97f2 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -6,6 +6,7 @@ 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/controller/server_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/util.dart'; @@ -64,6 +65,7 @@ class CollectionsController { CollectionsController( this._c, { required this.account, + required this.serverController, }); void dispose() { @@ -264,7 +266,7 @@ class CollectionsController { hasNext: false, ); final completer = Completer(); - ListCollection(_c)(account).listen( + ListCollection(_c, serverController: serverController)(account).listen( (c) { lastData = CollectionStreamEvent( data: _prepareDataFor(c), @@ -325,6 +327,7 @@ class CollectionsController { final DiContainer _c; final Account account; + final ServerController serverController; var _isDataStreamInited = false; final _dataStreamController = BehaviorSubject.seeded( diff --git a/app/lib/controller/server_controller.dart b/app/lib/controller/server_controller.dart new file mode 100644 index 00000000..9df55e28 --- /dev/null +++ b/app/lib/controller/server_controller.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/entity_converter.dart'; +import 'package:nc_photos/entity/server_status.dart'; +import 'package:nc_photos/np_api_util.dart'; +import 'package:np_api/np_api.dart' as api; +import 'package:np_codegen/np_codegen.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'server_controller.g.dart'; + +@npLog +class ServerController { + ServerController({ + required this.account, + }); + + ValueStream get status { + if (!_statusStreamContorller.hasValue) { + unawaited(_load()); + } + return _statusStreamContorller.stream; + } + + Future _load() => _getStatus(); + + Future _getStatus() async { + try { + final response = await ApiUtil.fromAccount(account).status().get(); + if (!response.isGood) { + _log.severe("[_getStatus] Failed requesting server: $response"); + return; + } + final apiStatus = await api.StatusParser().parse(response.body); + final status = ApiStatusConverter.fromApi(apiStatus); + _log.info("[_getStatus] Server status: $status"); + _statusStreamContorller.add(status); + } catch (e, stackTrace) { + _log.severe("[_getStatus] Failed while get", e, stackTrace); + return; + } + } + + final Account account; + + final _statusStreamContorller = BehaviorSubject(); +} diff --git a/app/lib/controller/server_controller.g.dart b/app/lib/controller/server_controller.g.dart new file mode 100644 index 00000000..a1bc8ed2 --- /dev/null +++ b/app/lib/controller/server_controller.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'server_controller.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$ServerControllerNpLog on ServerController { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("controller.server_controller.ServerController"); +} diff --git a/app/lib/entity/server_status.dart b/app/lib/entity/server_status.dart new file mode 100644 index 00000000..8e27ed81 --- /dev/null +++ b/app/lib/entity/server_status.dart @@ -0,0 +1,24 @@ +import 'package:to_string/to_string.dart'; + +part 'server_status.g.dart'; + +@toString +class ServerStatus { + const ServerStatus({ + required this.versionRaw, + required this.versionName, + required this.productName, + }); + + @override + String toString() => _$toString(); + + final String versionRaw; + final String versionName; + final String productName; +} + +extension ServerStatusExtension on ServerStatus { + List get versionNumber => versionRaw.split(".").map(int.parse).toList(); + int get majorVersion => versionNumber[0]; +} diff --git a/app/lib/entity/server_status.g.dart b/app/lib/entity/server_status.g.dart new file mode 100644 index 00000000..a5079609 --- /dev/null +++ b/app/lib/entity/server_status.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'server_status.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$ServerStatusToString on ServerStatus { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "ServerStatus {versionRaw: $versionRaw, versionName: $versionName, productName: $productName}"; + } +} diff --git a/app/lib/use_case/collection/list_collection.dart b/app/lib/use_case/collection/list_collection.dart index 633730d2..d30702be 100644 --- a/app/lib/use_case/collection/list_collection.dart +++ b/app/lib/use_case/collection/list_collection.dart @@ -1,16 +1,21 @@ import 'dart:async'; import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/server_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/collection/builder.dart'; import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/entity/server_status.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)); + ListCollection( + this._c, { + required this.serverController, + }) : assert(require(_c)); static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo2) && @@ -51,23 +56,29 @@ class ListCollection { onDone(); }, ); - ListNcAlbum(_c)(account).listen( - (event) { - ncAlbums = event; - notify(); - }, - onDone: () { - isNcAlbumDone = true; - onDone(); - }, - onError: (e, stackTrace) { - controller.addError(e, stackTrace); - isNcAlbumDone = true; - onDone(); - }, - ); + if (serverController.status.hasValue && + serverController.status.value.majorVersion < 25) { + isNcAlbumDone = true; + } else { + 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; + final ServerController serverController; } diff --git a/app/lib/widget/home.dart b/app/lib/widget/home.dart index 5a1a4638..f04b8457 100644 --- a/app/lib/widget/home.dart +++ b/app/lib/widget/home.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:event_bus/event_bus.dart'; 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/controller/account_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/data_source.dart'; @@ -64,6 +68,16 @@ class _HomeState extends State with TickerProviderStateMixin { }); } _animationController.value = 1; + + // call once to pre-cache the value + unawaited(context + .read() + .serverController + .status + .first + .then((value) { + _log.info("Server status: $value"); + })); } @override diff --git a/app/lib/widget/home_search_suggestion.dart b/app/lib/widget/home_search_suggestion.dart index 3ebfbb33..0daa5620 100644 --- a/app/lib/widget/home_search_suggestion.dart +++ b/app/lib/widget/home_search_suggestion.dart @@ -79,6 +79,7 @@ class _HomeSearchSuggestionState extends State _bloc = (widget.controller._bloc ??= HomeSearchSuggestionBloc( widget.account, context.read().collectionsController, + context.read().serverController, )); if (_bloc.state is! HomeSearchSuggestionBlocInit) { // process the current state diff --git a/np_api/lib/np_api.dart b/np_api/lib/np_api.dart index 5354a393..d3f7bf6e 100644 --- a/np_api/lib/np_api.dart +++ b/np_api/lib/np_api.dart @@ -8,6 +8,7 @@ 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'; +export 'src/entity/status_parser.dart'; export 'src/entity/tag_parser.dart'; export 'src/entity/tagged_file_parser.dart'; export 'src/type.dart'; diff --git a/np_api/lib/src/api.dart b/np_api/lib/src/api.dart index dcf0ac95..f28bc238 100644 --- a/np_api/lib/src/api.dart +++ b/np_api/lib/src/api.dart @@ -27,6 +27,8 @@ class Api { ApiPhotos photos(String userId) => ApiPhotos(this, userId); + ApiStatus status() => ApiStatus(this); + ApiSystemtags systemtags() => ApiSystemtags(this); ApiSystemtagsRelations systemtagsRelations() => ApiSystemtagsRelations(this); diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index 90a484f2..cfa59726 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -299,6 +299,7 @@ class Status with EquatableMixin { const Status({ required this.version, required this.versionString, + required this.productName, }); @override @@ -308,10 +309,12 @@ class Status with EquatableMixin { List get props => [ version, versionString, + productName, ]; final String version; final String versionString; + final String productName; } @toString diff --git a/np_api/lib/src/entity/status_parser.dart b/np_api/lib/src/entity/status_parser.dart index e30080f7..ca91d1ba 100644 --- a/np_api/lib/src/entity/status_parser.dart +++ b/np_api/lib/src/entity/status_parser.dart @@ -13,6 +13,7 @@ class StatusParser { return Status( version: json["version"], versionString: json["versionstring"], + productName: json["productname"], ); } } diff --git a/np_api/test/entity/status_parser_test.dart b/np_api/test/entity/status_parser_test.dart new file mode 100644 index 00000000..f66f069d --- /dev/null +++ b/np_api/test/entity/status_parser_test.dart @@ -0,0 +1,34 @@ +import 'package:np_api/np_api.dart'; +import 'package:test/test.dart'; + +void main() { + group("StatusParser", () { + group("parse", () { + test("Nextcloud 25", _nextcloud25); + }); + }); +} + +Future _nextcloud25() async { + const json = """ +{ + "installed": true, + "maintenance": false, + "needsDbUpgrade": false, + "version": "25.0.2.3", + "versionstring": "25.0.2", + "edition": "", + "productname": "Nextcloud", + "extendedSupport": false +} +"""; + final results = await StatusParser().parse(json); + expect( + results, + const Status( + version: "25.0.2.3", + versionString: "25.0.2", + productName: "Nextcloud", + ), + ); +} From f919e853af07316a4d7d84384a8d4ac8ea032fb5 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 11 May 2023 00:51:45 +0800 Subject: [PATCH 34/67] Show the server version in Settings --- app/lib/l10n/app_en.arb | 4 ++++ app/lib/l10n/untranslated-messages.txt | 11 +++++++++++ app/lib/widget/settings.dart | 26 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index f2c68245..455791c0 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -431,6 +431,10 @@ "@settingsVersionTitle": { "description": "Title of the version data item" }, + "settingsServerVersionTitle": "Server", + "@settingsServerVersionTitle": { + "description": "This item will show the server software version, e.g., Nextcloud 25" + }, "settingsSourceCodeTitle": "Source code", "@settingsSourceCodeTitle": { "description": "Title of the source code item" diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 3f0ec808..bbe4bf41 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -1,6 +1,7 @@ { "cs": [ "nameInputInvalidEmpty", + "settingsServerVersionTitle", "createCollectionFailureNotification", "addItemToCollectionTooltip", "addItemToCollectionFailureNotification", @@ -58,6 +59,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "rootPickerSkipConfirmationDialogContent2", "timeSecondInputHint", "sortOptionFilenameAscendingLabel", @@ -248,6 +250,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "slideshowSetupDialogReverseTitle", "shareMethodPreviewTitle", "shareMethodPreviewDescription", @@ -324,6 +327,7 @@ "es": [ "nameInputInvalidEmpty", + "settingsServerVersionTitle", "createCollectionFailureNotification", "addItemToCollectionTooltip", "addItemToCollectionFailureNotification", @@ -335,6 +339,7 @@ ], "fi": [ + "settingsServerVersionTitle", "createCollectionFailureNotification", "addItemToCollectionTooltip", "addItemToCollectionFailureNotification", @@ -377,6 +382,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", "helpTooltip", @@ -498,6 +504,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", "slideshowSetupDialogReverseTitle", @@ -605,6 +612,7 @@ "pt": [ "nameInputInvalidEmpty", + "settingsServerVersionTitle", "createCollectionFailureNotification", "addItemToCollectionTooltip", "addItemToCollectionFailureNotification", @@ -646,6 +654,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", "slideshowSetupDialogReverseTitle", @@ -764,6 +773,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", "slideshowSetupDialogReverseTitle", @@ -882,6 +892,7 @@ "settingsClearCacheDatabaseTitle", "settingsClearCacheDatabaseDescription", "settingsClearCacheDatabaseSuccessNotification", + "settingsServerVersionTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", "slideshowSetupDialogReverseTitle", diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index e51af555..dcf27c3a 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -2,12 +2,15 @@ import 'dart:async'; import 'package:event_bus/event_bus.dart'; 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/controller/account_controller.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/server_status.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; @@ -211,6 +214,29 @@ class _SettingsState extends State { } }, ), + StreamBuilder( + stream: + context.read().serverController.status, + initialData: context + .read() + .serverController + .status + .valueOrNull, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const ListTile( + title: Text("Server"), + ); + } else { + final status = snapshot.requireData!; + return ListTile( + title: const Text("Server"), + subtitle: Text( + "${status.productName} ${status.majorVersion} (${status.versionName})"), + ); + } + }, + ), ListTile( title: Text(L10n.global().settingsSourceCodeTitle), onTap: () { From 87872def7b3aa484c3eab61fb01bf72bdd76757c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 11 May 2023 01:05:17 +0800 Subject: [PATCH 35/67] Remove unused import --- app/lib/widget/collection_browser.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index fc12b990..050f0a10 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -25,7 +25,6 @@ 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/nc_album_item_adapter.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'; From b42bf7d74ae0fa0e40b826452d3a666ffa03a21b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 12 May 2023 01:11:23 +0800 Subject: [PATCH 36/67] Simplify server feature checking --- app/lib/controller/server_controller.dart | 12 ++++++++++++ app/lib/use_case/collection/list_collection.dart | 4 +--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/lib/controller/server_controller.dart b/app/lib/controller/server_controller.dart index 9df55e28..b0732db2 100644 --- a/app/lib/controller/server_controller.dart +++ b/app/lib/controller/server_controller.dart @@ -11,6 +11,10 @@ import 'package:rxdart/rxdart.dart'; part 'server_controller.g.dart'; +enum ServerFeature { + ncAlbum, +} + @npLog class ServerController { ServerController({ @@ -24,6 +28,14 @@ class ServerController { return _statusStreamContorller.stream; } + bool isSupported(ServerFeature feature) { + switch (feature) { + case ServerFeature.ncAlbum: + return !_statusStreamContorller.hasValue || + _statusStreamContorller.value.majorVersion >= 25; + } + } + Future _load() => _getStatus(); Future _getStatus() async { diff --git a/app/lib/use_case/collection/list_collection.dart b/app/lib/use_case/collection/list_collection.dart index d30702be..bed10c76 100644 --- a/app/lib/use_case/collection/list_collection.dart +++ b/app/lib/use_case/collection/list_collection.dart @@ -7,7 +7,6 @@ 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/entity/server_status.dart'; import 'package:nc_photos/use_case/album/list_album2.dart'; import 'package:nc_photos/use_case/nc_album/list_nc_album.dart'; @@ -56,8 +55,7 @@ class ListCollection { onDone(); }, ); - if (serverController.status.hasValue && - serverController.status.value.majorVersion < 25) { + if (!serverController.isSupported(ServerFeature.ncAlbum)) { isNcAlbumDone = true; } else { ListNcAlbum(_c)(account).listen( From 301c8bb404971e01ca9bc29ff05e74890c8c4965 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 12 May 2023 01:11:50 +0800 Subject: [PATCH 37/67] Hide NcAlbum from new collection dialog on Nextcloud <25 --- app/lib/widget/new_collection_dialog.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/lib/widget/new_collection_dialog.dart b/app/lib/widget/new_collection_dialog.dart index 51171cbc..113a4c29 100644 --- a/app/lib/widget/new_collection_dialog.dart +++ b/app/lib/widget/new_collection_dialog.dart @@ -7,6 +7,7 @@ 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/controller/server_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'; @@ -19,6 +20,8 @@ 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/object_extension.dart'; +import 'package:nc_photos/pref.dart'; 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'; @@ -47,7 +50,11 @@ class NewCollectionDialog extends StatelessWidget { account: account, supportedProviders: { _ProviderOption.appAlbum, - _ProviderOption.ncAlbum, + if (context + .read() + .serverController + .isSupported(ServerFeature.ncAlbum)) + _ProviderOption.ncAlbum, if (isAllowDynamic) ...{ _ProviderOption.dir, _ProviderOption.tag, From e56df922bbd1a640a503f02217e575ccf710854b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 12 May 2023 01:12:17 +0800 Subject: [PATCH 38/67] Remember prev choice in new collection dialog --- app/lib/pref.dart | 15 +++++++++++++++ app/lib/widget/new_collection_dialog/bloc.dart | 3 +++ .../new_collection_dialog/state_event.dart | 17 ++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/lib/pref.dart b/app/lib/pref.dart index 102b2f32..fdde5f35 100644 --- a/app/lib/pref.dart +++ b/app/lib/pref.dart @@ -382,6 +382,18 @@ class AccountPref { } } + int? getLastNewCollectionType() => + provider.getInt(PrefKey.lastNewCollectionType); + int getLastNewCollectionTypeOr(int def) => getLastNewCollectionType() ?? def; + Future setLastNewCollectionType(int? value) { + if (value == null) { + return _remove(PrefKey.lastNewCollectionType); + } else { + return _set(PrefKey.lastNewCollectionType, value, + (key, value) => provider.setInt(key, value)); + } + } + Future _set(PrefKey key, T value, Future Function(PrefKey key, T value) setFn) async { if (await setFn(key, value)) { @@ -621,6 +633,7 @@ enum PrefKey { isEnableMemoryAlbum, touchRootEtag, accountLabel, + lastNewCollectionType, } extension on PrefKey { @@ -710,6 +723,8 @@ extension on PrefKey { return "touchRootEtag"; case PrefKey.accountLabel: return "accountLabel"; + case PrefKey.lastNewCollectionType: + return "lastNewCollectionType"; } } } diff --git a/app/lib/widget/new_collection_dialog/bloc.dart b/app/lib/widget/new_collection_dialog/bloc.dart index 12639748..1f674960 100644 --- a/app/lib/widget/new_collection_dialog/bloc.dart +++ b/app/lib/widget/new_collection_dialog/bloc.dart @@ -6,6 +6,7 @@ class _Bloc extends Bloc<_Event, _State> { required this.account, required Set<_ProviderOption> supportedProviders, }) : super(_State.init( + account: account, supportedProviders: supportedProviders, )) { on<_FormEvent>(_onFormEvent); @@ -63,6 +64,8 @@ class _Bloc extends Bloc<_Event, _State> { contentProvider: _buildProvider(), ), )); + unawaited(AccountPref.of(account) + .setLastNewCollectionType(state.formValue.provider.index)); } CollectionContentProvider _buildProvider() { diff --git a/app/lib/widget/new_collection_dialog/state_event.dart b/app/lib/widget/new_collection_dialog/state_event.dart index f5c664b8..6fcdefd9 100644 --- a/app/lib/widget/new_collection_dialog/state_event.dart +++ b/app/lib/widget/new_collection_dialog/state_event.dart @@ -25,11 +25,26 @@ class _State { }); factory _State.init({ + required Account account, required Set<_ProviderOption> supportedProviders, }) { + final prevType = + AccountPref.of(account).getLastNewCollectionType()?.run((t) { + try { + return _ProviderOption.values[t]; + } catch (_) { + return null; + } + }); + var provider = prevType ?? _ProviderOption.ncAlbum; + if (!supportedProviders.contains(provider)) { + provider = _ProviderOption.appAlbum; + } return _State( supportedProviders: supportedProviders, - formValue: const _FormValue(), + formValue: _FormValue( + provider: provider, + ), showDialog: true, ); } From 155990e7bdc78d459b6236eca34b7ad6df605e8f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 13 May 2023 02:33:10 +0800 Subject: [PATCH 39/67] Remove obsolete code --- app/lib/entity/album/provider.dart | 21 --------------------- app/lib/use_case/preprocess_album.dart | 3 +-- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/app/lib/entity/album/provider.dart b/app/lib/entity/album/provider.dart index 1d47a5d0..ba5af393 100644 --- a/app/lib/entity/album/provider.dart +++ b/app/lib/entity/album/provider.dart @@ -263,24 +263,3 @@ class AlbumTagProvider extends AlbumDynamicProvider { static const _type = "tag"; } - -/// Smart albums are created only by the app and not the user -abstract class AlbumSmartProvider extends AlbumProviderBase { - AlbumSmartProvider({ - DateTime? latestItemTime, - }) : super(latestItemTime: latestItemTime); - - @override - AlbumSmartProvider copyWith({ - OrNull? latestItemTime, - }) { - // Smart albums do not support copying - throw UnimplementedError(); - } - - @override - toContentJson() { - // Smart albums do not support saving - throw UnimplementedError(); - } -} diff --git a/app/lib/use_case/preprocess_album.dart b/app/lib/use_case/preprocess_album.dart index 52d924db..c8d4c534 100644 --- a/app/lib/use_case/preprocess_album.dart +++ b/app/lib/use_case/preprocess_album.dart @@ -22,8 +22,7 @@ class PreProcessAlbum { Future> call(Account account, Album album) { if (album.provider is AlbumStaticProvider) { return ResyncAlbum(_c)(account, album); - } else if (album.provider is AlbumDynamicProvider || - album.provider is AlbumSmartProvider) { + } else if (album.provider is AlbumDynamicProvider) { return PopulateAlbum(_c)(account, album); } else { throw ArgumentError( From cc3780587b52ad5aa746b4f9012cb9eb6b808517 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 13 May 2023 02:33:24 +0800 Subject: [PATCH 40/67] Fix typo --- app/lib/entity/collection.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index 858860d3..5c99b74b 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -41,7 +41,7 @@ class Collection with EquatableMixin { /// See [CollectionContentProvider.itemSort] CollectionItemSort get itemSort => contentProvider.itemSort; - /// See [CollectionContentProvider.sharees] + /// See [CollectionContentProvider.shares] List get shares => contentProvider.shares; /// See [CollectionContentProvider.getCoverUrl] From 6b9175de183d7512883a927fddadb2fb4e1ccf47 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 13 May 2023 02:44:21 +0800 Subject: [PATCH 41/67] Regression: only open dynamic collections after creating a new one --- app/lib/entity/collection.dart | 9 +++++++++ .../entity/collection/content_provider/album.dart | 3 +++ .../content_provider/location_group.dart | 3 +++ .../collection/content_provider/memory.dart | 3 +++ .../collection/content_provider/nc_album.dart | 3 +++ .../collection/content_provider/person.dart | 3 +++ .../entity/collection/content_provider/tag.dart | 3 +++ app/lib/widget/home_collections.dart | 15 ++++++++++----- 8 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index 5c99b74b..93e9d6c6 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -58,6 +58,9 @@ class Collection with EquatableMixin { CollectionSorter getSorter() => CollectionSorter.fromSortType(itemSort); + /// See [CollectionContentProvider.isDynamicCollection] + bool get isDynamicCollection => contentProvider.isDynamicCollection; + @override List get props => [ name, @@ -127,4 +130,10 @@ abstract class CollectionContentProvider with EquatableMixin { int height, { bool? isKeepAspectRatio, }); + + /// Return whether this is a dynamic collection + /// + /// A collection is defined as a dynamic one when the items are not specified + /// explicitly by the user, but rather derived from some conditions + bool get isDynamicCollection; } diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index 93c62bad..3aca7682 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -99,6 +99,9 @@ class CollectionAlbumProvider } } + @override + bool get isDynamicCollection => album.provider is! AlbumStaticProvider; + @override List get props => [account, album]; diff --git a/app/lib/entity/collection/content_provider/location_group.dart b/app/lib/entity/collection/content_provider/location_group.dart index cf809b48..513af6fa 100644 --- a/app/lib/entity/collection/content_provider/location_group.dart +++ b/app/lib/entity/collection/content_provider/location_group.dart @@ -50,6 +50,9 @@ class CollectionLocationGroupProvider ); } + @override + bool get isDynamicCollection => true; + @override List get props => [account, location]; diff --git a/app/lib/entity/collection/content_provider/memory.dart b/app/lib/entity/collection/content_provider/memory.dart index a8fcfdf3..003b1256 100644 --- a/app/lib/entity/collection/content_provider/memory.dart +++ b/app/lib/entity/collection/content_provider/memory.dart @@ -58,6 +58,9 @@ class CollectionMemoryProvider )); } + @override + bool get isDynamicCollection => true; + @override String toString() => _$toString(); diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index 609446e3..b0995579 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -73,6 +73,9 @@ class CollectionNcAlbumProvider } } + @override + bool get isDynamicCollection => false; + @override List get props => [account, album]; diff --git a/app/lib/entity/collection/content_provider/person.dart b/app/lib/entity/collection/content_provider/person.dart index f9be868c..72bdfcd3 100644 --- a/app/lib/entity/collection/content_provider/person.dart +++ b/app/lib/entity/collection/content_provider/person.dart @@ -48,6 +48,9 @@ class CollectionPersonProvider size: math.max(width, height)); } + @override + bool get isDynamicCollection => true; + @override List get props => [account, person]; diff --git a/app/lib/entity/collection/content_provider/tag.dart b/app/lib/entity/collection/content_provider/tag.dart index 729160b7..79fba8e7 100644 --- a/app/lib/entity/collection/content_provider/tag.dart +++ b/app/lib/entity/collection/content_provider/tag.dart @@ -43,6 +43,9 @@ class CollectionTagProvider }) => null; + @override + bool get isDynamicCollection => true; + @override List get props => [account, tags]; diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index d5e8131e..464918f3 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -240,11 +240,16 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> if (collection == null) { return; } - // open the newly created collection - unawaited(Navigator.of(context).pushNamed( - CollectionBrowser.routeName, - arguments: CollectionBrowserArguments(collection), - )); + // Right now we don't have a way to add photos inside the + // CollectionBrowser, eventually we should add that and remove this + // branching + if (collection.isDynamicCollection) { + // 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( From f20900f2a9f94bfdebbb949dd32a786ee94cf186 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 13 May 2023 20:45:35 +0800 Subject: [PATCH 42/67] Fix shared album removed instead of unimporting it --- app/lib/entity/collection/adapter/album.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index b4b6352c..203f3437 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -29,6 +29,7 @@ import 'package:nc_photos/use_case/album/remove_from_album.dart'; import 'package:nc_photos/use_case/album/share_album_with_user.dart'; import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; +import 'package:nc_photos/use_case/unimport_shared_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/ci_string.dart'; @@ -260,7 +261,13 @@ class CollectionAlbumAdapter implements CollectionAdapter { } @override - Future remove() => RemoveAlbum(_c)(account, _provider.album); + Future remove() { + if (_provider.album.albumFile?.isOwned(account.userId) == true) { + return RemoveAlbum(_c)(account, _provider.album); + } else { + return UnimportSharedAlbum(_c)(account, _provider.album); + } + } @override bool isPermitted(CollectionCapability capability) { From fb02f3c9b00402ee9f51145756371645cd30b034 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 May 2023 21:23:27 +0800 Subject: [PATCH 43/67] Regression: import album shared with you --- .../2.0x/ic_add_collections_outlined_24dp.png | Bin 0 -> 309 bytes .../3.0x/ic_add_collections_outlined_24dp.png | Bin 0 -> 362 bytes .../ic_add_collections_outlined_24dp.png | Bin 0 -> 286 bytes app/lib/asset.dart | 2 ++ app/lib/entity/collection.dart | 9 +++++++ app/lib/entity/collection/adapter.dart | 3 +++ .../collection/adapter/adapter_mixin.dart | 5 ++++ app/lib/entity/collection/adapter/album.dart | 8 ++++++ .../collection/content_provider/album.dart | 7 ++++++ .../content_provider/location_group.dart | 3 +++ .../collection/content_provider/memory.dart | 3 +++ .../collection/content_provider/nc_album.dart | 3 +++ .../collection/content_provider/person.dart | 3 +++ .../collection/content_provider/tag.dart | 3 +++ .../import_pending_shared_collection.dart | 18 ++++++++++++++ app/lib/widget/asset_icon.dart | 23 ++++++++++++++++++ app/lib/widget/collection_browser.dart | 15 ++++++++++++ app/lib/widget/collection_browser.g.dart | 15 +++++++++++- .../widget/collection_browser/app_bar.dart | 10 ++++++++ app/lib/widget/collection_browser/bloc.dart | 12 +++++++++ .../collection_browser/state_event.dart | 11 +++++++++ app/lib/widget/sharing_browser.dart | 10 ++++++-- 22 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 app/assets/2.0x/ic_add_collections_outlined_24dp.png create mode 100644 app/assets/3.0x/ic_add_collections_outlined_24dp.png create mode 100644 app/assets/ic_add_collections_outlined_24dp.png create mode 100644 app/lib/asset.dart create mode 100644 app/lib/use_case/collection/import_pending_shared_collection.dart create mode 100644 app/lib/widget/asset_icon.dart diff --git a/app/assets/2.0x/ic_add_collections_outlined_24dp.png b/app/assets/2.0x/ic_add_collections_outlined_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d50cc2312fdcdd88e3e7731bbf664694d878c642 GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3TAX~PbvH$7ERG^?_ ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0wAJY5_^GVZ;d;mFnOz~fr) z6+Z7s?fd$cm%G#prOmmV4cAI7cdcn`c%bs(%={yg#pQEv=2`7zWH@k@@!dskWACl% z<=dzIoFKrk_WOY--gj4j1cEp~a3KGg!?*Zlp>`@P4q;t)ycif9?kqRnmnb(yYh3^% z!@451WlZTJ8`ao4d*_P7UncE(Svlbgm&k`7<_zK8D}8GkY>kC}h0JoD{$#8B x>u>AUKk^a{zjo~GxtTLOc^J}@v_RIV-IwyQ^sgNCr>*?y}vd$@?2>@ zwM1DsfJ%O|ojP;r)Ao-sX1aTdgB+uHfMV|)U*xjNC+HP1x^w_E0%vh@J%z!C!H*Ky!hQDbzl%Mc)I$ztaD0e0suQRhH(G@ literal 0 HcmV?d00001 diff --git a/app/assets/ic_add_collections_outlined_24dp.png b/app/assets/ic_add_collections_outlined_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..08536503852213c1f9dfd082c910e783fb2451fd GIT binary patch literal 286 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-!PT^vI^c&~;;a~)9Ni9Y_$ z;Ktwc+p;Y-M`j!qcWU{0dF`@govxgUIcK&S^B=!i(sWo$VB)g)i++rCBJ6LXSQCz1 z^4q_^>bSDSuBQLOJ2YQauKO)jeVIk(uF}uH!4Kapc%EtZ{e#7|I~|4Zoz95=<(tEl zdfxi2=H*>MIzm$#%$9Fka;Gz+_?q4>*F2xAU9(&(@0I24_B(YiV2ekmPQkb8wXU0E gx4nA7^@4?e*=ZY7W4-lzfX-&{boFyt=akR{0KC<9Qvd(} literal 0 HcmV?d00001 diff --git a/app/lib/asset.dart b/app/lib/asset.dart new file mode 100644 index 00000000..1f7893cb --- /dev/null +++ b/app/lib/asset.dart @@ -0,0 +1,2 @@ +const icAddCollectionsOutlined24 = + "assets/ic_add_collections_outlined_24dp.png"; diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index 93e9d6c6..dd747e90 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -61,6 +61,9 @@ class Collection with EquatableMixin { /// See [CollectionContentProvider.isDynamicCollection] bool get isDynamicCollection => contentProvider.isDynamicCollection; + /// See [CollectionContentProvider.isPendingSharedAlbum] + bool get isPendingSharedAlbum => contentProvider.isPendingSharedAlbum; + @override List get props => [ name, @@ -136,4 +139,10 @@ abstract class CollectionContentProvider with EquatableMixin { /// A collection is defined as a dynamic one when the items are not specified /// explicitly by the user, but rather derived from some conditions bool get isDynamicCollection; + + /// Return whether this is a shared album pending to be added + /// + /// In some implementation, shared album does not immediately get added to the + /// collections list + bool get isPendingSharedAlbum; } diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index a348e089..ce144716 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -86,6 +86,9 @@ abstract class CollectionAdapter { required ValueChanged onCollectionUpdated, }); + /// Import a pending shared collection and return the resulting collection + Future importPendingShared(); + /// Convert a [NewCollectionItem] to an adapted one Future adaptToNewItem(NewCollectionItem original); diff --git a/app/lib/entity/collection/adapter/adapter_mixin.dart b/app/lib/entity/collection/adapter/adapter_mixin.dart index 8deda148..94dedea8 100644 --- a/app/lib/entity/collection/adapter/adapter_mixin.dart +++ b/app/lib/entity/collection/adapter/adapter_mixin.dart @@ -75,4 +75,9 @@ mixin CollectionAdapterUnshareableTag implements CollectionAdapter { }) { throw UnsupportedError("Operation not supported"); } + + @override + Future importPendingShared() { + throw UnsupportedError("Operation not supported"); + } } diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 203f3437..e261e5ed 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -28,6 +28,7 @@ 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/album/share_album_with_user.dart'; import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; +import 'package:nc_photos/use_case/import_pending_shared_album.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/use_case/unimport_shared_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; @@ -224,6 +225,13 @@ class CollectionAlbumAdapter implements CollectionAdapter { : CollectionShareResult.ok; } + @override + Future importPendingShared() async { + final newAlbum = + await ImportPendingSharedAlbum(_c)(account, _provider.album); + return CollectionBuilder.byAlbum(account, newAlbum); + } + @override Future adaptToNewItem(NewCollectionItem original) async { if (original is NewCollectionFileItem) { diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index 3aca7682..361a6c2f 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -7,6 +7,7 @@ import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; import 'package:to_string/to_string.dart'; part 'album.g.dart'; @@ -102,6 +103,12 @@ class CollectionAlbumProvider @override bool get isDynamicCollection => album.provider is! AlbumStaticProvider; + @override + bool get isPendingSharedAlbum => + album.albumFile?.path.startsWith( + remote_storage_util.getRemotePendingSharedAlbumsDir(account)) == + true; + @override List get props => [account, album]; diff --git a/app/lib/entity/collection/content_provider/location_group.dart b/app/lib/entity/collection/content_provider/location_group.dart index 513af6fa..5a4cac2b 100644 --- a/app/lib/entity/collection/content_provider/location_group.dart +++ b/app/lib/entity/collection/content_provider/location_group.dart @@ -53,6 +53,9 @@ class CollectionLocationGroupProvider @override bool get isDynamicCollection => true; + @override + bool get isPendingSharedAlbum => false; + @override List get props => [account, location]; diff --git a/app/lib/entity/collection/content_provider/memory.dart b/app/lib/entity/collection/content_provider/memory.dart index 003b1256..0411d4ea 100644 --- a/app/lib/entity/collection/content_provider/memory.dart +++ b/app/lib/entity/collection/content_provider/memory.dart @@ -61,6 +61,9 @@ class CollectionMemoryProvider @override bool get isDynamicCollection => true; + @override + bool get isPendingSharedAlbum => false; + @override String toString() => _$toString(); diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index b0995579..9499701e 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -76,6 +76,9 @@ class CollectionNcAlbumProvider @override bool get isDynamicCollection => false; + @override + bool get isPendingSharedAlbum => false; + @override List get props => [account, album]; diff --git a/app/lib/entity/collection/content_provider/person.dart b/app/lib/entity/collection/content_provider/person.dart index 72bdfcd3..42d16ab6 100644 --- a/app/lib/entity/collection/content_provider/person.dart +++ b/app/lib/entity/collection/content_provider/person.dart @@ -51,6 +51,9 @@ class CollectionPersonProvider @override bool get isDynamicCollection => true; + @override + bool get isPendingSharedAlbum => false; + @override List get props => [account, person]; diff --git a/app/lib/entity/collection/content_provider/tag.dart b/app/lib/entity/collection/content_provider/tag.dart index 79fba8e7..de736f60 100644 --- a/app/lib/entity/collection/content_provider/tag.dart +++ b/app/lib/entity/collection/content_provider/tag.dart @@ -46,6 +46,9 @@ class CollectionTagProvider @override bool get isDynamicCollection => true; + @override + bool get isPendingSharedAlbum => false; + @override List get props => [account, tags]; diff --git a/app/lib/use_case/collection/import_pending_shared_collection.dart b/app/lib/use_case/collection/import_pending_shared_collection.dart new file mode 100644 index 00000000..53eba2a7 --- /dev/null +++ b/app/lib/use_case/collection/import_pending_shared_collection.dart @@ -0,0 +1,18 @@ +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'; + +class ImportPendingSharedCollection { + const ImportPendingSharedCollection(this._c); + + /// Import a pending shared collection to the app + /// + /// For some implementations, shared collection may live in a temporary + /// state before being accepted by the user. This use case will accept the + /// share and import the collection to the collections view + Future call(Account account, Collection collection) => + CollectionAdapter.of(_c, account, collection).importPendingShared(); + + final DiContainer _c; +} diff --git a/app/lib/widget/asset_icon.dart b/app/lib/widget/asset_icon.dart new file mode 100644 index 00000000..32beec2c --- /dev/null +++ b/app/lib/widget/asset_icon.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class AssetIcon extends StatelessWidget { + const AssetIcon( + this.assetName, { + super.key, + this.size, + this.color, + }); + + @override + Widget build(BuildContext context) { + return ImageIcon( + AssetImage(assetName), + size: size, + color: color, + ); + } + + final String assetName; + final double? size; + final Color? color; +} diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 050f0a10..dafa3b0a 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -13,6 +13,7 @@ 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/asset.dart' as asset; import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/cache_manager_util.dart'; import 'package:nc_photos/controller/account_controller.dart'; @@ -39,8 +40,10 @@ import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/archive_file.dart'; +import 'package:nc_photos/use_case/collection/import_pending_shared_collection.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/remove.dart'; +import 'package:nc_photos/widget/asset_icon.dart'; import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/draggable_item_list.dart'; import 'package:nc_photos/widget/export_collection_dialog.dart'; @@ -164,6 +167,18 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> } }, ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.importResult != current.importResult, + listener: (context, state) { + if (state.importResult != null) { + Navigator.of(context).pushReplacementNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(state.importResult!), + ); + } + }, + ), BlocListener<_Bloc, _State>( listenWhen: (previous, current) => previous.error != current.error, diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index ad8c948b..9729e7d8 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -29,6 +29,7 @@ abstract class $_StateCopyWithWorker { List<_Item>? editTransformedItems, CollectionItemSort? editSort, bool? isDragging, + Collection? importResult, ExceptionEvent? error, String? message}); } @@ -53,6 +54,7 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { dynamic editTransformedItems = copyWithNull, dynamic editSort = copyWithNull, dynamic isDragging, + dynamic importResult = copyWithNull, dynamic error = copyWithNull, dynamic message = copyWithNull}) { return _State( @@ -82,6 +84,9 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { ? that.editSort : editSort as CollectionItemSort?, isDragging: isDragging as bool? ?? that.isDragging, + importResult: importResult == copyWithNull + ? that.importResult + : importResult as Collection?, error: error == copyWithNull ? that.error : error as ExceptionEvent?, message: message == copyWithNull ? that.message : message as String?); } @@ -128,7 +133,7 @@ extension _$_BlocNpLog on _Bloc { 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}"}, isDragging: $isDragging, error: $error, message: $message}"; + 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}"}, isDragging: $isDragging, importResult: $importResult, error: $error, message: $message}"; } } @@ -153,6 +158,14 @@ extension _$_TransformItemsToString on _TransformItems { } } +extension _$_ImportPendingSharedCollectionToString + on _ImportPendingSharedCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ImportPendingSharedCollection {}"; + } +} + extension _$_DownloadToString on _Download { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 97a2c9a3..4f0d1ed7 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -33,6 +33,12 @@ class _AppBar extends StatelessWidget { icon: const Icon(Icons.share), tooltip: L10n.global().shareTooltip, ), + if (state.collection.isPendingSharedAlbum) + IconButton( + onPressed: () => _onAddToCollectionsViewPressed(context), + icon: const AssetIcon(asset.icAddCollectionsOutlined24), + tooltip: L10n.global().addToCollectionsViewTooltip, + ), ]; if (state.items.isNotEmpty || canRename) { actions.add(PopupMenuButton<_MenuOption>( @@ -125,6 +131,10 @@ class _AppBar extends StatelessWidget { ), ); } + + Future _onAddToCollectionsViewPressed(BuildContext context) async { + context.read<_Bloc>().add(const _ImportPendingSharedCollection()); + } } class _AppBarCover extends StatelessWidget { diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index a5f0ffdc..ad0567c5 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -19,6 +19,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_UpdateCollection>(_onUpdateCollection); on<_LoadItems>(_onLoad); on<_TransformItems>(_onTransformItems); + on<_ImportPendingSharedCollection>(_onImportPendingSharedCollection); on<_Download>(_onDownload); @@ -56,6 +57,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { add(_UpdateCollection(c.collection)); } }); + } else { + _log.info("[_Bloc] Ad hoc collection"); } _itemsControllerSubscription = itemsController.stream.listen( (_) {}, @@ -121,6 +124,15 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { } } + Future _onImportPendingSharedCollection( + _ImportPendingSharedCollection ev, Emitter<_State> emit) async { + _log.info(ev); + // pending collections are always ad hoc + final newCollection = + await ImportPendingSharedCollection(_c)(account, state.collection); + emit(state.copyWith(importResult: newCollection)); + } + void _onBeginEdit(_BeginEdit ev, Emitter<_State> emit) { _log.info("$ev"); emit(state.copyWith(isEditMode: true)); diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index 01b5fa9c..fa2de2e2 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -19,6 +19,7 @@ class _State { this.editTransformedItems, this.editSort, required this.isDragging, + this.importResult, this.error, this.message, }); @@ -66,6 +67,8 @@ class _State { final bool isDragging; + final Collection? importResult; + final ExceptionEvent? error; final String? message; } @@ -104,6 +107,14 @@ class _TransformItems implements _Event { final List items; } +@toString +class _ImportPendingSharedCollection implements _Event { + const _ImportPendingSharedCollection(); + + @override + String toString() => _$toString(); +} + @toString class _Download implements _Event { const _Download(); diff --git a/app/lib/widget/sharing_browser.dart b/app/lib/widget/sharing_browser.dart index e5c76186..3766245f 100644 --- a/app/lib/widget/sharing_browser.dart +++ b/app/lib/widget/sharing_browser.dart @@ -10,6 +10,7 @@ import 'package:nc_photos/bloc/list_sharing.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/collection/builder.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/share.dart'; @@ -20,7 +21,7 @@ import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/import_potential_shared_album.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/empty_list_indicator.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/shared_file_viewer.dart'; @@ -252,7 +253,12 @@ class _SharingBrowserState extends State { } void _onAlbumShareItemTap(BuildContext context, ListSharingAlbum share) { - album_browser_util.push(context, widget.account, share.album); + Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byAlbum(widget.account, share.album), + ), + ); } void _transformItems(List items) { From 022184f30e7f0a19178d3d7d9db09ef3224cceca Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 May 2023 21:23:55 +0800 Subject: [PATCH 44/67] Tweak UI --- app/lib/widget/share_collection_dialog.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/widget/share_collection_dialog.dart b/app/lib/widget/share_collection_dialog.dart index 5dc2ebe5..b566d1b2 100644 --- a/app/lib/widget/share_collection_dialog.dart +++ b/app/lib/widget/share_collection_dialog.dart @@ -161,7 +161,6 @@ class _ShareeInputViewState extends State<_ShareeInputView> { hintText: L10n.global().addUserInputHint, ), ), - direction: AxisDirection.up, suggestionsCallback: _onSearch, itemBuilder: (context, suggestion) => ListTile( title: Text(suggestion.label), From 929f28209816d0bd53613df4ef01b552e4c3560d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 16 May 2023 22:58:53 +0800 Subject: [PATCH 45/67] Fix moving files back and forth causing cache out sync --- app/lib/entity/file/data_source.dart | 20 ++++++++++++++--- app/lib/entity/file/file_cache_manager.dart | 4 +--- app/lib/entity/sqlite/database_extension.dart | 22 ++++++++++++------- app/lib/use_case/cache_favorite.dart | 4 ++-- app/test/test_util.dart | 7 +++--- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 5a76f483..2c7d4f22 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -549,13 +549,20 @@ class FileSqliteDbDataSource implements FileDataSource { } @override - move( + Future move( Account account, File f, String destination, { bool? shouldOverwrite, }) async { - // do nothing + _log.info("[move] ${f.path} to $destination"); + await _c.sqliteDb.use((db) async { + await db.moveFileByFileId( + sql.ByAccount.app(account), + f.fileId!, + File(path: destination).strippedPathWithEmpty, + ); + }); } @override @@ -786,7 +793,7 @@ class FileCachedDataSource implements FileDataSource { } @override - move( + Future move( Account account, File f, String destination, { @@ -794,6 +801,13 @@ class FileCachedDataSource implements FileDataSource { }) async { await _remoteSrc.move(account, f, destination, shouldOverwrite: shouldOverwrite); + try { + await _sqliteDbSrc.move(account, f, destination); + } catch (e, stackTrace) { + // ignore cache failure + _log.warning( + "Failed while move: ${logFilename(f.strippedPath)}", e, stackTrace); + } } @override diff --git a/app/lib/entity/file/file_cache_manager.dart b/app/lib/entity/file/file_cache_manager.dart index 2dfc4a86..5f804eb9 100644 --- a/app/lib/entity/file/file_cache_manager.dart +++ b/app/lib/entity/file/file_cache_manager.dart @@ -191,9 +191,7 @@ class FileSqliteCacheUpdater { ) async { // query list of rowIds for files in [remoteFiles] final rowIds = await db.accountFileRowIdsByFileIds( - remoteFiles.map((f) => f.fileId!), - sqlAccount: dbAccount, - ); + sql.ByAccount.sql(dbAccount), remoteFiles.map((f) => f.fileId!)); final rowIdsMap = Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e))); final inserts = []; diff --git a/app/lib/entity/sqlite/database_extension.dart b/app/lib/entity/sqlite/database_extension.dart index 26c0b34b..fa6655af 100644 --- a/app/lib/entity/sqlite/database_extension.dart +++ b/app/lib/entity/sqlite/database_extension.dart @@ -320,11 +320,7 @@ extension SqliteDbExtension on SqliteDb { /// /// Returned files are NOT guaranteed to be sorted as [fileIds] Future> accountFileRowIdsByFileIds( - Iterable fileIds, { - Account? sqlAccount, - app.Account? appAccount, - }) { - assert((sqlAccount != null) != (appAccount != null)); + ByAccount account, Iterable fileIds) { return fileIds.withPartition((sublist) { final query = queryFiles().run((q) { q.setQueryMode(FilesQueryMode.expression, expressions: [ @@ -333,10 +329,10 @@ extension SqliteDbExtension on SqliteDb { accountFiles.file, files.fileId, ]); - if (sqlAccount != null) { - q.setSqlAccount(sqlAccount); + if (account.sqlAccount != null) { + q.setSqlAccount(account.sqlAccount!); } else { - q.setAppAccount(appAccount!); + q.setAppAccount(account.appAccount!); } q.byFileIds(sublist); return q.build(); @@ -477,6 +473,16 @@ extension SqliteDbExtension on SqliteDb { }, maxByFileIdsSize); } + Future moveFileByFileId( + ByAccount account, int fileId, String destinationRelativePath) async { + final rowId = (await accountFileRowIdsByFileIds(account, [fileId])).first; + final q = update(accountFiles) + ..where((t) => t.rowId.equals(rowId.accountFileRowId)); + await q.write(AccountFilesCompanion( + relativePath: Value(destinationRelativePath), + )); + } + Future> allTags({ Account? sqlAccount, app.Account? appAccount, diff --git a/app/lib/use_case/cache_favorite.dart b/app/lib/use_case/cache_favorite.dart index 99de33ba..3ac8bce2 100644 --- a/app/lib/use_case/cache_favorite.dart +++ b/app/lib/use_case/cache_favorite.dart @@ -42,8 +42,8 @@ class CacheFavorite { var updateCount = 0; if (newFileIds.isNotEmpty) { - final rowIds = await db.accountFileRowIdsByFileIds(newFileIds, - sqlAccount: dbAccount); + final rowIds = await db.accountFileRowIdsByFileIds( + sql.ByAccount.sql(dbAccount), newFileIds); final counts = await rowIds.map((id) => id.accountFileRowId).withPartition( (sublist) async { diff --git a/app/test/test_util.dart b/app/test/test_util.dart index 0bf7d90d..8ae033f2 100644 --- a/app/test/test_util.dart +++ b/app/test/test_util.dart @@ -478,12 +478,11 @@ Future insertFiles( Future insertDirRelation( sql.SqliteDb db, Account account, File dir, Iterable children) async { final dbAccount = await db.accountOf(account); - final dirRowIds = (await db - .accountFileRowIdsByFileIds([dir.fileId!], sqlAccount: dbAccount)) + final dirRowIds = (await db.accountFileRowIdsByFileIds( + sql.ByAccount.sql(dbAccount), [dir.fileId!])) .first; final childRowIds = await db.accountFileRowIdsByFileIds( - [dir, ...children].map((f) => f.fileId!), - sqlAccount: dbAccount); + sql.ByAccount.sql(dbAccount), [dir, ...children].map((f) => f.fileId!)); await db.batch((batch) { batch.insertAll( db.dirFiles, From b9aea0c20808a7e6c73ba76f0a2bee4b2e899040 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 17 May 2023 01:05:57 +0800 Subject: [PATCH 46/67] Fix link shared dir can't be opened --- app/lib/bloc/list_sharing.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/bloc/list_sharing.dart b/app/lib/bloc/list_sharing.dart index 100c306c..68b91528 100644 --- a/app/lib/bloc/list_sharing.dart +++ b/app/lib/bloc/list_sharing.dart @@ -304,6 +304,7 @@ class ListSharingBloc extends Bloc { s, File( path: webdavPath, + fileId: s.itemSource, isCollection: true, ), ); From b4e8e609dbda19b27fb9153b8a9c2743815d4d21 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 17 May 2023 01:06:54 +0800 Subject: [PATCH 47/67] Add toString to fd --- app/lib/entity/file_descriptor.dart | 7 +++++++ app/lib/entity/file_descriptor.g.dart | 14 ++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 app/lib/entity/file_descriptor.g.dart diff --git a/app/lib/entity/file_descriptor.dart b/app/lib/entity/file_descriptor.dart index 94dc5e6b..efec4e06 100644 --- a/app/lib/entity/file_descriptor.dart +++ b/app/lib/entity/file_descriptor.dart @@ -2,6 +2,9 @@ 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; +import 'package:to_string/to_string.dart'; + +part 'file_descriptor.g.dart'; int compareFileDescriptorDateTimeDescending( FileDescriptor x, FileDescriptor y) { @@ -14,6 +17,7 @@ int compareFileDescriptorDateTimeDescending( } } +@toString class FileDescriptor with EquatableMixin { const FileDescriptor({ required this.fdPath, @@ -42,6 +46,9 @@ class FileDescriptor with EquatableMixin { "fdDateTime": that.fdDateTime.toUtc().toIso8601String(), }; + @override + String toString() => _$toString(); + JsonObj toFdJson() => toJson(this); @override diff --git a/app/lib/entity/file_descriptor.g.dart b/app/lib/entity/file_descriptor.g.dart new file mode 100644 index 00000000..06816861 --- /dev/null +++ b/app/lib/entity/file_descriptor.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_descriptor.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$FileDescriptorToString on FileDescriptor { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "FileDescriptor {fdPath: $fdPath, fdId: $fdId, fdMime: $fdMime, fdIsArchived: $fdIsArchived, fdIsFavorite: $fdIsFavorite, fdDateTime: $fdDateTime}"; + } +} From f272132045fcef95fcc14877cc42ae2f3d07b3fb Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 18 May 2023 01:40:08 +0800 Subject: [PATCH 48/67] Tweak icon --- app/lib/widget/home_collections.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 464918f3..2b12743c 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -509,7 +509,7 @@ class _ItemView extends StatelessWidget { Widget? icon; switch (item.itemType) { case _ItemType.ncAlbum: - icon = const ImageIcon(AssetImage("assets/ic_nextcloud_album.png")); + icon = const Icon(Icons.cloud); break; case _ItemType.album: icon = null; From efd5db62ff866b97abb089cfea4f4dcd3d463568 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 18 May 2023 01:41:02 +0800 Subject: [PATCH 49/67] Regression: shared label for shared collections in HomeCollections --- app/lib/widget/home_collections.dart | 7 ++++++- app/lib/widget/home_collections/type.dart | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 2b12743c..e6422050 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -521,13 +521,18 @@ class _ItemView extends StatelessWidget { icon = const Icon(Icons.folder); break; } + String subtitle = ""; + if (item.isShared) { + subtitle = "${L10n.global().albumSharedLabel} | "; + } + subtitle += item.subtitle ?? ""; return CollectionGridItem( cover: _CollectionCover( account: account, url: item.coverUrl, ), title: item.name, - subtitle: item.subtitle, + subtitle: subtitle, icon: icon, ); } diff --git a/app/lib/widget/home_collections/type.dart b/app/lib/widget/home_collections/type.dart index 6d00c92c..e257ae5e 100644 --- a/app/lib/widget/home_collections/type.dart +++ b/app/lib/widget/home_collections/type.dart @@ -9,7 +9,7 @@ enum _ItemType { @npLog class _Item implements SelectableItemMetadata { - _Item(this.collection) { + _Item(this.collection) : isShared = collection.shares.isNotEmpty { if (collection.count != null) { _subtitle = L10n.global().albumSize(collection.count!); } @@ -61,6 +61,8 @@ class _Item implements SelectableItemMetadata { } final Collection collection; + final bool isShared; + String? _subtitle; String? _coverUrl; late _ItemType _itemType; From 05e1aecbf336d332f4db807b946885011d8ca3b8 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 18 May 2023 01:42:08 +0800 Subject: [PATCH 50/67] Graduate shared album from experiments --- app/lib/pref.dart | 8 --- app/lib/widget/home.dart | 12 ++-- app/lib/widget/settings.dart | 92 ++++------------------------- app/lib/widget/settings.g.dart | 7 --- app/lib/widget/sharing_browser.dart | 18 +++--- 5 files changed, 22 insertions(+), 115 deletions(-) diff --git a/app/lib/pref.dart b/app/lib/pref.dart index fdde5f35..1c8d0c0c 100644 --- a/app/lib/pref.dart +++ b/app/lib/pref.dart @@ -182,14 +182,6 @@ class Pref { Future setGpsMapProvider(int value) => _set(PrefKey.gpsMapProvider, value, (key, value) => provider.setInt(key, value)); - bool? isLabEnableSharedAlbum() => - provider.getBool(PrefKey.labEnableSharedAlbum); - bool isLabEnableSharedAlbumOr(bool def) => isLabEnableSharedAlbum() ?? def; - Future setLabEnableSharedAlbum(bool value) => _set( - PrefKey.labEnableSharedAlbum, - value, - (key, value) => provider.setBool(key, value)); - bool? hasShownSharedAlbumInfo() => provider.getBool(PrefKey.hasShownSharedAlbumInfo); bool hasShownSharedAlbumInfoOr(bool def) => hasShownSharedAlbumInfo() ?? def; diff --git a/app/lib/widget/home.dart b/app/lib/widget/home.dart index f04b8457..8dfc3777 100644 --- a/app/lib/widget/home.dart +++ b/app/lib/widget/home.dart @@ -60,13 +60,11 @@ class _HomeState extends State with TickerProviderStateMixin { @override initState() { super.initState(); - if (Pref().isLabEnableSharedAlbumOr(false)) { - _importPotentialSharedAlbum().then((value) { - if (value.isNotEmpty) { - AccountPref.of(widget.account).setNewSharedAlbum(true); - } - }); - } + _importPotentialSharedAlbum().then((value) { + if (value.isNotEmpty) { + AccountPref.of(widget.account).setNewSharedAlbum(true); + } + }); _animationController.value = 1; // call once to pre-cache the value diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index dcf27c3a..a2e8b3c0 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -181,14 +181,14 @@ class _SettingsState extends State { label: L10n.global().settingsMiscellaneousTitle, builder: () => const _MiscSettings(), ), - if (_enabledExperiments.isNotEmpty) - _buildSubSettings( - context, - leading: const Icon(Icons.science_outlined), - label: L10n.global().settingsExperimentalTitle, - description: L10n.global().settingsExperimentalDescription, - builder: () => _ExperimentalSettings(), - ), + // if (_enabledExperiments.isNotEmpty) + // _buildSubSettings( + // context, + // leading: const Icon(Icons.science_outlined), + // label: L10n.global().settingsExperimentalTitle, + // description: L10n.global().settingsExperimentalDescription, + // builder: () => _ExperimentalSettings(), + // ), _buildSubSettings( context, leading: const Icon(Icons.warning_amber), @@ -1582,73 +1582,6 @@ class _MiscSettingsState extends State<_MiscSettings> { late bool _isDoubleTapExit; } -class _ExperimentalSettings extends StatefulWidget { - @override - createState() => _ExperimentalSettingsState(); -} - -@npLog -class _ExperimentalSettingsState extends State<_ExperimentalSettings> { - @override - initState() { - super.initState(); - _isEnableSharedAlbum = Pref().isLabEnableSharedAlbumOr(false); - } - - @override - build(BuildContext context) { - return Scaffold( - body: Builder( - builder: (context) => _buildContent(context), - ), - ); - } - - Widget _buildContent(BuildContext context) { - return CustomScrollView( - slivers: [ - SliverAppBar( - pinned: true, - title: Text(L10n.global().settingsExperimentalTitle), - ), - SliverList( - delegate: SliverChildListDelegate( - [ - if (_enabledExperiments.contains(_Experiment.sharedAlbum)) - SwitchListTile( - title: const Text("Shared album"), - subtitle: - const Text("Share albums with users on the same server"), - value: _isEnableSharedAlbum, - onChanged: (value) => _onEnableSharedAlbumChanged(value), - ), - ], - ), - ), - ], - ); - } - - Future _onEnableSharedAlbumChanged(bool value) async { - final oldValue = _isEnableSharedAlbum; - setState(() { - _isEnableSharedAlbum = value; - }); - if (!await Pref().setLabEnableSharedAlbum(value)) { - _log.severe("[_onEnableSharedAlbumChanged] Failed writing pref"); - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().writePreferenceFailureNotification), - duration: k.snackBarDurationNormal, - )); - setState(() { - _isEnableSharedAlbum = oldValue; - }); - } - } - - late bool _isEnableSharedAlbum; -} - class _DevSettings extends StatefulWidget { @override createState() => _DevSettingsState(); @@ -1718,10 +1651,5 @@ Widget _buildCaption(BuildContext context, String label) { ); } -enum _Experiment { - sharedAlbum, -} - -final _enabledExperiments = [ - _Experiment.sharedAlbum, -]; +// final _enabledExperiments = [ +// ]; diff --git a/app/lib/widget/settings.g.dart b/app/lib/widget/settings.g.dart index b0aeb946..c06cfb04 100644 --- a/app/lib/widget/settings.g.dart +++ b/app/lib/widget/settings.g.dart @@ -55,13 +55,6 @@ extension _$_MiscSettingsStateNpLog on _MiscSettingsState { static final log = Logger("widget.settings._MiscSettingsState"); } -extension _$_ExperimentalSettingsStateNpLog on _ExperimentalSettingsState { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.settings._ExperimentalSettingsState"); -} - extension _$_DevSettingsStateNpLog on _DevSettingsState { // ignore: unused_element Logger get _log => log; diff --git a/app/lib/widget/sharing_browser.dart b/app/lib/widget/sharing_browser.dart index 3766245f..76c3dd51 100644 --- a/app/lib/widget/sharing_browser.dart +++ b/app/lib/widget/sharing_browser.dart @@ -66,18 +66,14 @@ class _SharingBrowserState extends State { @override initState() { super.initState(); - if (Pref().isLabEnableSharedAlbumOr(false)) { - _importPotentialSharedAlbum().whenComplete(() { - _initBloc(); - }); - AccountPref.of(widget.account).run((obj) { - if (obj.hasNewSharedAlbumOr()) { - obj.setNewSharedAlbum(false); - } - }); - } else { + _importPotentialSharedAlbum().whenComplete(() { _initBloc(); - } + }); + AccountPref.of(widget.account).run((obj) { + if (obj.hasNewSharedAlbumOr()) { + obj.setNewSharedAlbum(false); + } + }); } @override From c6aced336ea86cb990ec5636e33e07c81e239234 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 18 May 2023 01:43:19 +0800 Subject: [PATCH 51/67] Remove obsolete files --- app/lib/bloc/list_share.dart | 96 -- app/lib/bloc/list_share.g.dart | 39 - app/lib/bloc/list_sharee.dart | 111 -- app/lib/bloc/list_sharee.g.dart | 39 - app/lib/use_case/list_dir_share.dart | 37 - app/lib/widget/album_browser.dart | 1067 ----------------- app/lib/widget/album_browser.g.dart | 25 - app/lib/widget/album_browser_app_bar.dart | 141 --- app/lib/widget/album_browser_mixin.dart | 262 ---- app/lib/widget/album_browser_mixin.g.dart | 14 - app/lib/widget/album_browser_util.dart | 22 - app/lib/widget/draggable_item_list_mixin.dart | 59 - app/lib/widget/share_album_dialog.dart | 346 ------ app/lib/widget/share_album_dialog.g.dart | 14 - 14 files changed, 2272 deletions(-) delete mode 100644 app/lib/bloc/list_share.dart delete mode 100644 app/lib/bloc/list_share.g.dart delete mode 100644 app/lib/bloc/list_sharee.dart delete mode 100644 app/lib/bloc/list_sharee.g.dart delete mode 100644 app/lib/use_case/list_dir_share.dart delete mode 100644 app/lib/widget/album_browser.dart delete mode 100644 app/lib/widget/album_browser.g.dart delete mode 100644 app/lib/widget/album_browser_app_bar.dart delete mode 100644 app/lib/widget/album_browser_mixin.dart delete mode 100644 app/lib/widget/album_browser_mixin.g.dart delete mode 100644 app/lib/widget/album_browser_util.dart delete mode 100644 app/lib/widget/draggable_item_list_mixin.dart delete mode 100644 app/lib/widget/share_album_dialog.dart delete mode 100644 app/lib/widget/share_album_dialog.g.dart diff --git a/app/lib/bloc/list_share.dart b/app/lib/bloc/list_share.dart deleted file mode 100644 index e9f4cbd3..00000000 --- a/app/lib/bloc/list_share.dart +++ /dev/null @@ -1,96 +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/entity/file.dart'; -import 'package:nc_photos/entity/share.dart'; -import 'package:nc_photos/entity/share/data_source.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'list_share.g.dart'; - -abstract class ListShareBlocEvent { - const ListShareBlocEvent(); -} - -@toString -class ListShareBlocQuery extends ListShareBlocEvent { - const ListShareBlocQuery(this.account, this.file); - - @override - String toString() => _$toString(); - - final Account account; - final File file; -} - -@toString -abstract class ListShareBlocState { - const ListShareBlocState(this.account, this.file, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final File file; - final List items; -} - -class ListShareBlocInit extends ListShareBlocState { - ListShareBlocInit() : super(null, File(path: ""), const []); -} - -class ListShareBlocLoading extends ListShareBlocState { - const ListShareBlocLoading(Account? account, File file, List items) - : super(account, file, items); -} - -class ListShareBlocSuccess extends ListShareBlocState { - const ListShareBlocSuccess(Account? account, File file, List items) - : super(account, file, items); -} - -@toString -class ListShareBlocFailure extends ListShareBlocState { - const ListShareBlocFailure( - Account? account, File file, List items, this.exception) - : super(account, file, items); - - @override - String toString() => _$toString(); - - final dynamic exception; -} - -/// List all shares from a given file -@npLog -class ListShareBloc extends Bloc { - ListShareBloc() : super(ListShareBlocInit()) { - on(_onEvent); - } - - Future _onEvent( - ListShareBlocEvent event, Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListShareBlocQuery) { - await _onEventQuery(event, emit); - } - } - - Future _onEventQuery( - ListShareBlocQuery ev, Emitter emit) async { - try { - emit(ListShareBlocLoading(ev.account, ev.file, state.items)); - emit(ListShareBlocSuccess(ev.account, ev.file, await _query(ev))); - } catch (e, stackTrace) { - _log.severe("[_onEventQuery] Exception while request", e, stackTrace); - emit(ListShareBlocFailure(ev.account, ev.file, state.items, e)); - } - } - - Future> _query(ListShareBlocQuery ev) { - final shareRepo = ShareRepo(ShareRemoteDataSource()); - return shareRepo.list(ev.account, ev.file); - } -} diff --git a/app/lib/bloc/list_share.g.dart b/app/lib/bloc/list_share.g.dart deleted file mode 100644 index 1cec35af..00000000 --- a/app/lib/bloc/list_share.g.dart +++ /dev/null @@ -1,39 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_share.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListShareBlocNpLog on ListShareBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_share.ListShareBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListShareBlocQueryToString on ListShareBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListShareBlocQuery {account: $account, file: ${file.path}}"; - } -} - -extension _$ListShareBlocStateToString on ListShareBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListShareBlocState")} {account: $account, file: ${file.path}, items: [length: ${items.length}]}"; - } -} - -extension _$ListShareBlocFailureToString on ListShareBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListShareBlocFailure {account: $account, file: ${file.path}, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/bloc/list_sharee.dart b/app/lib/bloc/list_sharee.dart deleted file mode 100644 index 53449943..00000000 --- a/app/lib/bloc/list_sharee.dart +++ /dev/null @@ -1,111 +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/entity/sharee.dart'; -import 'package:nc_photos/entity/sharee/data_source.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'list_sharee.g.dart'; - -abstract class ListShareeBlocEvent { - const ListShareeBlocEvent(); -} - -@toString -class ListShareeBlocQuery extends ListShareeBlocEvent { - const ListShareeBlocQuery(this.account); - - @override - String toString() => _$toString(); - - final Account account; -} - -@toString -abstract class ListShareeBlocState { - const ListShareeBlocState(this.account, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final List items; -} - -class ListShareeBlocInit extends ListShareeBlocState { - ListShareeBlocInit() : super(null, const []); -} - -class ListShareeBlocLoading extends ListShareeBlocState { - const ListShareeBlocLoading(Account? account, List items) - : super(account, items); -} - -class ListShareeBlocSuccess extends ListShareeBlocState { - const ListShareeBlocSuccess(Account? account, List items) - : super(account, items); -} - -@toString -class ListShareeBlocFailure extends ListShareeBlocState { - const ListShareeBlocFailure( - Account? account, List items, this.exception) - : super(account, items); - - @override - String toString() => _$toString(); - - final dynamic exception; -} - -/// List all sharees of this account -@npLog -class ListShareeBloc extends Bloc { - ListShareeBloc() : super(ListShareeBlocInit()) { - on(_onEvent); - } - - static ListShareeBloc of(Account account) { - final name = bloc_util.getInstNameForAccount("ListShareeBloc", 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 bloc = ListShareeBloc(); - KiwiContainer().registerInstance(bloc, name: name); - return bloc; - } - } - - Future _onEvent( - ListShareeBlocEvent event, Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListShareeBlocQuery) { - await _onEventQuery(event, emit); - } - } - - Future _onEventQuery( - ListShareeBlocQuery ev, Emitter emit) async { - try { - emit(ListShareeBlocLoading(ev.account, state.items)); - emit(ListShareeBlocSuccess(ev.account, await _query(ev))); - } catch (e, stackTrace) { - _log.shout("[_onEventQuery] Exception while request", e, stackTrace); - emit(ListShareeBlocFailure(ev.account, state.items, e)); - } - } - - Future> _query(ListShareeBlocQuery ev) { - final shareeRepo = ShareeRepo(ShareeRemoteDataSource()); - return shareeRepo.list(ev.account); - } - - static final _log = _$ListShareeBlocNpLog.log; -} diff --git a/app/lib/bloc/list_sharee.g.dart b/app/lib/bloc/list_sharee.g.dart deleted file mode 100644 index 5b0f292b..00000000 --- a/app/lib/bloc/list_sharee.g.dart +++ /dev/null @@ -1,39 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_sharee.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListShareeBlocNpLog on ListShareeBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_sharee.ListShareeBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListShareeBlocQueryToString on ListShareeBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListShareeBlocQuery {account: $account}"; - } -} - -extension _$ListShareeBlocStateToString on ListShareeBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListShareeBlocState")} {account: $account, items: [length: ${items.length}]}"; - } -} - -extension _$ListShareeBlocFailureToString on ListShareeBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListShareeBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/use_case/list_dir_share.dart b/app/lib/use_case/list_dir_share.dart deleted file mode 100644 index 8989a39a..00000000 --- a/app/lib/use_case/list_dir_share.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/share.dart'; - -class ListDirShareItem { - const ListDirShareItem(this.file, this.shares); - - /// The File returned contains only fileId and path. If you need other fields, - /// you must query the file again - final File file; - final List shares; -} - -class ListDirShare { - const ListDirShare(this.shareRepo); - - /// List all shares from a given dir - Future> call(Account account, File dir) async { - final shares = await shareRepo.listDir(account, dir); - final shareGroups = >{}; - for (final s in shares) { - shareGroups[s.itemSource] ??= []; - shareGroups[s.itemSource]!.add(s); - } - return shareGroups.entries - .map((e) => ListDirShareItem( - File( - path: file_util.unstripPath(account, e.value.first.path), - fileId: e.key, - ), - e.value)) - .toList(); - } - - final ShareRepo shareRepo; -} diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart deleted file mode 100644 index a084529f..00000000 --- a/app/lib/widget/album_browser.dart +++ /dev/null @@ -1,1067 +0,0 @@ -import 'dart:async'; - -import 'package:clock/clock.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.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/di_container.dart'; -import 'package:nc_photos/download_handler.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/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/event/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/list_extension.dart'; -import 'package:nc_photos/object_extension.dart'; -import 'package:nc_photos/or_null.dart'; -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/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_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; -import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; -import 'package:nc_photos/widget/share_album_dialog.dart'; -import 'package:nc_photos/widget/shared_album_info_dialog.dart'; -import 'package:nc_photos/widget/simple_input_dialog.dart'; -import 'package:nc_photos/widget/viewer.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'album_browser.g.dart'; - -class AlbumBrowserArguments { - AlbumBrowserArguments(this.account, this.album); - - final Account account; - final Album album; -} - -class AlbumBrowser extends StatefulWidget { - static const routeName = "/album-browser"; - - static Route buildRoute(AlbumBrowserArguments args) => MaterialPageRoute( - builder: (context) => AlbumBrowser.fromArgs(args), - ); - - const AlbumBrowser({ - Key? key, - required this.account, - required this.album, - }) : super(key: key); - - AlbumBrowser.fromArgs(AlbumBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - album: args.album, - ); - - @override - createState() => _AlbumBrowserState(); - - final Account account; - final Album album; -} - -@npLog -class _AlbumBrowserState extends State - with - SelectableItemStreamListMixin, - DraggableItemListMixin, - AlbumBrowserMixin { - _AlbumBrowserState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - assert(PreProcessAlbum.require(c)); - _c = c; - } - - static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); - - @override - initState() { - super.initState(); - _initAlbum(); - - _albumUpdatedListener = - AppEventListener(_onAlbumUpdatedEvent); - _albumUpdatedListener.begin(); - } - - @override - dispose() { - super.dispose(); - _albumUpdatedListener.end(); - } - - @override - build(BuildContext context) { - return Scaffold( - body: Builder( - builder: (context) { - if (isEditMode) { - return Form( - key: _editFormKey, - child: _buildContent(context), - ); - } else { - return _buildContent(context); - } - }, - ), - ); - } - - @override - onItemTap(SelectableItem item, int index) { - item.as<_ListItem>()?.onTap?.call(); - } - - @override - @protected - get canEdit => _album?.albumFile?.isOwned(widget.account.userId) == true; - - @override - enterEditMode() { - super.enterEditMode(); - _editAlbum = _album!.copyWith(); - setState(() { - _transformItems(); - }); - - if (!SessionStorage().hasShowDragRearrangeNotification) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().albumEditDragRearrangeNotification), - duration: k.snackBarDurationNormal, - )); - SessionStorage().hasShowDragRearrangeNotification = true; - } - } - - @override - validateEditMode() => _editFormKey.currentState?.validate() == true; - - @override - doneEditMode() { - try { - // persist the changes - _editFormKey.currentState!.save(); - final newAlbum = makeEdited(_editAlbum!); - if (newAlbum.copyWith(lastUpdated: OrNull(_album!.lastUpdated)) != - _album) { - _log.info("[doneEditMode] Album modified: $newAlbum"); - setState(() { - _album = newAlbum; - }); - UpdateAlbum(_c.albumRepo)( - widget.account, - newAlbum, - ).catchError((e, stackTrace) { - _log.shout("[doneEditMode] Failed while UpdateAlbum", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - }); - } else { - _log.fine("[doneEditMode] Album not modified"); - } - } finally { - setState(() { - // reset edits - _editAlbum = null; - // update the list to show the real album - _transformItems(); - }); - } - } - - Future _initAlbum() async { - var album = await _c.albumRepo.get(widget.account, widget.album.albumFile!); - if (widget.album.shares?.isNotEmpty == true) { - try { - final file = - await LsSingleFile(_c)(widget.account, album.albumFile!.path); - if (file.etag != album.albumFile!.etag) { - _log.info("[_initAlbum] Album modified in remote, forcing download"); - album = await _c.albumRepo.get(widget.account, File(path: file.path)); - } - } catch (e, stackTrace) { - _log.warning("[_initAlbum] Failed while syncing remote album file", e, - stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - } - } - await _setAlbum(album); - - if (album.shares?.isNotEmpty == true) { - unawaited(_showSharedAlbumInfoDialog()); - } - } - - Widget _buildContent(BuildContext context) { - if (_album == null) { - return CustomScrollView( - slivers: [ - buildNormalAppBar(context, widget.account, widget.album), - const SliverToBoxAdapter( - child: LinearProgressIndicator(), - ), - ], - ); - } - - Widget content = CustomScrollView( - controller: _scrollController, - slivers: [ - _buildAppBar(context), - isEditMode - ? buildDraggableItemList( - maxCrossAxisExtent: thumbSize.toDouble(), - onMaxExtentChanged: (value) { - _itemListMaxExtent = value; - }, - ) - : buildItemStreamList( - maxCrossAxisExtent: thumbSize.toDouble(), - ), - ], - ); - if (isEditMode) { - content = Listener( - onPointerMove: _onEditPointerMove, - child: content, - ); - } - return buildItemStreamListOuter(context, child: content); - } - - Widget _buildAppBar(BuildContext context) { - if (isEditMode) { - return _buildEditAppBar(context); - } else if (isSelectionMode) { - return _buildSelectionAppBar(context); - } else { - return buildNormalAppBar( - context, - widget.account, - _album!, - actions: [ - if (_album!.albumFile!.isOwned(widget.account.userId) && - Pref().isLabEnableSharedAlbumOr(false)) - IconButton( - onPressed: () => _onSharePressed(context), - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - ), - ], - menuItemBuilder: (_) => [ - if (Pref().isLabEnableSharedAlbumOr(false)) - PopupMenuItem( - value: _menuValueFixShares, - child: Text(L10n.global().fixSharesTooltip), - ), - PopupMenuItem( - value: _menuValueDownload, - child: Text(L10n.global().downloadTooltip), - ), - ], - onSelectedMenuItem: (option) => _onMenuSelected(context, option), - ); - } - } - - Widget _buildSelectionAppBar(BuildContext context) { - return buildSelectionAppBar(context, [ - IconButton( - icon: const Icon(Icons.share), - tooltip: L10n.global().shareTooltip, - onPressed: () { - _onSelectionSharePressed(context); - }, - ), - IconButton( - icon: const Icon(Icons.add), - tooltip: L10n.global().addItemToCollectionTooltip, - onPressed: () => _onSelectionAddPressed(context), - ), - PopupMenuButton<_SelectionMenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - if (_canRemoveSelection) - PopupMenuItem( - value: _SelectionMenuOption.removeFromAlbum, - child: Text(L10n.global().removeFromAlbumTooltip), - ), - PopupMenuItem( - value: _SelectionMenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - ], - onSelected: (option) => _onSelectionMenuSelected(context, option), - ), - ]); - } - - Widget _buildEditAppBar(BuildContext context) { - return buildEditAppBar(context, widget.account, _album!, actions: [ - IconButton( - icon: const Icon(Icons.text_fields), - tooltip: L10n.global().albumAddTextTooltip, - onPressed: _onEditAddTextPressed, - ), - IconButton( - icon: const Icon(Icons.sort_by_alpha), - tooltip: L10n.global().sortTooltip, - onPressed: _onEditSortPressed, - ), - ]); - } - - void _onItemTap(int index) { - // convert item index to file index - var fileIndex = index; - for (int i = 0; i < index; ++i) { - if (_sortedItems[i] is! AlbumFileItem || - !file_util - .isSupportedFormat((_sortedItems[i] as AlbumFileItem).file)) { - --fileIndex; - } - } - Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); - } - - Future _onSharePressed(BuildContext context) async { - await _showSharedAlbumInfoDialog(); - await showDialog( - context: context, - builder: (_) => ShareAlbumDialog( - account: widget.account, - album: _album!, - ), - ); - } - - void _onMenuSelected(BuildContext context, int option) { - switch (option) { - case _menuValueDownload: - _onDownloadPressed(); - break; - case _menuValueFixShares: - _onFixSharesPressed(); - break; - default: - _log.shout("[_onMenuSelected] Unknown option: $option"); - break; - } - } - - void _onDownloadPressed() { - final c = KiwiContainer().resolve(); - DownloadHandler(c).downloadFiles( - widget.account, - _sortedItems.whereType().map((e) => e.file).toList(), - parentDir: _album!.name, - ); - } - - void _onFixSharesPressed() { - Navigator.of(context).pushNamed( - AlbumShareOutlierBrowser.routeName, - arguments: AlbumShareOutlierBrowserArguments(widget.account, _album!), - ); - } - - void _onSelectionMenuSelected( - BuildContext context, _SelectionMenuOption option) { - switch (option) { - case _SelectionMenuOption.download: - _onSelectionDownloadPressed(); - break; - case _SelectionMenuOption.removeFromAlbum: - _onSelectionRemovePressed(); - break; - default: - _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); - break; - } - } - - void _onSelectionSharePressed(BuildContext context) { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType<_FileListItem>() - .map((e) => e.file) - .toList(); - if (selected.isEmpty) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().shareSelectedEmptyNotification), - duration: k.snackBarDurationNormal, - )); - return; - } - ShareHandler( - c, - context: context, - clearSelection: () { - setState(() { - clearSelectedItems(); - }); - }, - ).shareFiles(widget.account, selected); - } - - Future _onSelectionAddPressed(BuildContext context) async { - return const AddSelectionToCollectionHandler()( - context: context, - selection: selectedListItems - .whereType<_FileListItem>() - .map((e) => e.file) - .toList(), - clearSelection: () { - if (mounted) { - setState(() { - clearSelectedItems(); - }); - } - }, - ); - } - - Future _onSelectionRemovePressed() async { - final selectedIndexes = - selectedListItems.whereType<_ListItem>().map((e) => e.index).toList(); - final selectedItems = _sortedItems - .takeIndex(selectedIndexes) - // can only remove owned files - .where((element) => - _album!.albumFile!.isOwned(widget.account.userId) == true || - element.addedBy == widget.account.userId) - .toList(); - setState(() { - clearSelectedItems(); - }); - - try { - await RemoveFromAlbum(KiwiContainer().resolve())( - widget.account, _album!, selectedItems); - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global() - .removeSelectedFromAlbumSuccessNotification(selectedItems.length)), - duration: k.snackBarDurationNormal, - )); - } catch (e, stackTrace) { - _log.shout("[_onSelectionRemovePressed] Failed while updating album", e, - stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: - Text("${L10n.global().removeSelectedFromAlbumFailureNotification}: " - "${exception_util.toUserString(e)}"), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onSelectionDownloadPressed() { - final c = KiwiContainer().resolve(); - final selected = selectedListItems - .whereType<_FileListItem>() - .map((e) => e.file) - .toList(); - DownloadHandler(c).downloadFiles(widget.account, selected); - setState(() { - clearSelectedItems(); - }); - } - - void _onEditPointerMove(PointerMoveEvent event) { - assert(isEditMode); - if (!_isDragging) { - return; - } - if (event.position.dy >= MediaQuery.of(context).size.height - 100) { - // near bottom of screen - if (_isDragScrollingDown == true) { - return; - } - final maxExtent = - _itemListMaxExtent ?? _scrollController.position.maxScrollExtent; - _log.fine("[_onEditPointerMove] Begin scrolling down"); - if (_scrollController.offset < - _scrollController.position.maxScrollExtent) { - _scrollController.animateTo(maxExtent, - duration: Duration( - milliseconds: - ((maxExtent - _scrollController.offset) * 1.6).round()), - curve: Curves.linear); - _isDragScrollingDown = true; - } - } else if (event.position.dy <= 100) { - // near top of screen - if (_isDragScrollingDown == false) { - return; - } - _log.fine("[_onEditPointerMove] Begin scrolling up"); - if (_scrollController.offset > 0) { - _scrollController.animateTo(0, - duration: Duration( - milliseconds: (_scrollController.offset * 1.6).round()), - curve: Curves.linear); - _isDragScrollingDown = false; - } - } else if (_isDragScrollingDown != null) { - _log.fine("[_onEditPointerMove] Stop scrolling"); - _scrollController.jumpTo(_scrollController.offset); - _isDragScrollingDown = null; - } - } - - void _onEditItemMoved(int fromIndex, int toIndex, bool isBefore) { - if (fromIndex == toIndex) { - return; - } - final item = _sortedItems.removeAt(fromIndex); - final newIndex = - toIndex + (isBefore ? 0 : 1) + (fromIndex < toIndex ? -1 : 0); - _sortedItems.insert(newIndex, item); - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumNullSortProvider(), - // save the current order - provider: AlbumStaticProvider.of(_editAlbum!).copyWith( - items: _sortedItems, - ), - ); - setState(() { - _transformItems(); - }); - } - - void _onEditSortPressed() { - final sortProvider = _editAlbum!.sortProvider; - showDialog( - context: context, - builder: (context) => FancyOptionPicker( - title: L10n.global().sortOptionDialogTitle, - items: [ - FancyOptionPickerItem( - label: L10n.global().sortOptionTimeDescendingLabel, - isSelected: sortProvider is AlbumTimeSortProvider && - !sortProvider.isAscending, - onSelect: () { - _onEditSortNewestPressed(); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionTimeAscendingLabel, - isSelected: sortProvider is AlbumTimeSortProvider && - sortProvider.isAscending, - onSelect: () { - _onEditSortOldestPressed(); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionFilenameAscendingLabel, - isSelected: sortProvider is AlbumFilenameSortProvider && - sortProvider.isAscending, - onSelect: () { - _onEditSortFilenamePressed(); - Navigator.of(context).pop(); - }, - ), - FancyOptionPickerItem( - label: L10n.global().sortOptionFilenameDescendingLabel, - isSelected: sortProvider is AlbumFilenameSortProvider && - !sortProvider.isAscending, - onSelect: () { - _onEditSortFilenameDescendingPressed(); - Navigator.of(context).pop(); - }, - ), - if (sortProvider is AlbumNullSortProvider) - FancyOptionPickerItem( - label: L10n.global().sortOptionManualLabel, - isSelected: true, - onSelect: () { - Navigator.of(context).pop(); - }, - ), - ], - ), - ); - } - - void _onEditSortOldestPressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumTimeSortProvider(isAscending: true), - ); - setState(() { - _transformItems(); - }); - } - - void _onEditSortNewestPressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumTimeSortProvider(isAscending: false), - ); - setState(() { - _transformItems(); - }); - } - - void _onEditSortFilenamePressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumFilenameSortProvider(isAscending: true), - ); - setState(() { - _transformItems(); - }); - } - - void _onEditSortFilenameDescendingPressed() { - _editAlbum = _editAlbum!.copyWith( - sortProvider: const AlbumFilenameSortProvider(isAscending: false), - ); - setState(() { - _transformItems(); - }); - } - - void _onEditAddTextPressed() { - showDialog( - context: context, - builder: (context) => SimpleInputDialog( - buttonText: MaterialLocalizations.of(context).saveButtonLabel, - ), - ).then((value) { - if (value == null) { - return; - } - _editAlbum = _editAlbum!.copyWith( - provider: AlbumStaticProvider.of(_editAlbum!).copyWith( - items: [ - AlbumLabelItem( - addedBy: widget.account.userId, - addedAt: clock.now(), - text: value, - ), - ..._sortedItems, - ], - ), - ); - setState(() { - _transformItems(); - }); - }); - } - - void _onEditLabelItemEditPressed(AlbumLabelItem item, int index) { - showDialog( - context: context, - builder: (context) => SimpleInputDialog( - buttonText: MaterialLocalizations.of(context).saveButtonLabel, - initialText: item.text, - ), - ).then((value) { - if (value == null) { - return; - } - _sortedItems[index] = item.copyWith( - text: value, - ); - _editAlbum = _editAlbum!.copyWith( - provider: AlbumStaticProvider.of(_editAlbum!).copyWith( - items: _sortedItems, - ), - ); - setState(() { - _transformItems(); - }); - }); - } - - Future _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) async { - if (ev.album.albumFile!.path == _album?.albumFile?.path) { - await _setAlbum(ev.album); - } - } - - void _transformItems() { - if (_editAlbum != null) { - // edit mode - // _sortedItems = - // _editAlbum!.sortProvider.sort(_getAlbumItemsOf(_editAlbum!)); - _sortedItems = _getAlbumItemsOf(_editAlbum!); - } else { - // _sortedItems = _album!.sortProvider.sort(_getAlbumItemsOf(_album!)); - _sortedItems = _getAlbumItemsOf(_album!); - } - _backingFiles = _sortedItems - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .toList(); - final dateHelper = photo_list_util.DateGroupHelper( - isMonthOnly: false, - ); - - final items = () sync* { - for (int i = 0; i < _sortedItems.length; ++i) { - final item = _sortedItems[i]; - if (item is AlbumFileItem) { - final previewUrl = - NetworkRectThumbnail.imageUrlForFile(widget.account, item.file); - if ((_editAlbum ?? _album)?.sortProvider is AlbumTimeSortProvider && - Pref().isAlbumBrowserShowDateOr()) { - final date = dateHelper.onFile(item.file); - if (date != null) { - yield _DateListItem(date: date); - } - } - - if (file_util.isSupportedImageFormat(item.file)) { - yield _ImageListItem( - index: i, - file: item.file, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - onDropBefore: (dropItem) => - _onEditItemMoved((dropItem as _ListItem).index, i, true), - onDropAfter: (dropItem) => - _onEditItemMoved((dropItem as _ListItem).index, i, false), - onDragStarted: () { - _isDragging = true; - }, - onDragEndedAny: () { - _isDragging = false; - }, - ); - } else if (file_util.isSupportedVideoFormat(item.file)) { - yield _VideoListItem( - index: i, - file: item.file, - account: widget.account, - previewUrl: previewUrl, - onTap: () => _onItemTap(i), - onDropBefore: (dropItem) => - _onEditItemMoved((dropItem as _ListItem).index, i, true), - onDropAfter: (dropItem) => - _onEditItemMoved((dropItem as _ListItem).index, i, false), - onDragStarted: () { - _isDragging = true; - }, - onDragEndedAny: () { - _isDragging = false; - }, - ); - } else { - _log.shout( - "[_transformItems] Unsupported file format: ${item.file.contentType}"); - } - } else if (item is AlbumLabelItem) { - if (isEditMode) { - yield _EditLabelListItem( - index: i, - text: item.text, - onEditPressed: () => _onEditLabelItemEditPressed(item, i), - onDropBefore: (dropItem) => - _onEditItemMoved((dropItem as _ListItem).index, i, true), - onDropAfter: (dropItem) => - _onEditItemMoved((dropItem as _ListItem).index, i, false), - onDragStarted: () { - _isDragging = true; - }, - onDragEndedAny: () { - _isDragging = false; - }, - ); - } else { - yield _LabelListItem( - index: i, - text: item.text, - ); - } - } - } - }() - .toList(); - itemStreamListItems = items; - draggableItemList = items; - } - - Future _setAlbum(Album album) async { - assert(album.provider is AlbumStaticProvider); - final items = await PreProcessAlbum(_c)(widget.account, album); - if (album.albumFile!.isOwned(widget.account.userId)) { - album = await _updateAlbumPostResync(album, items); - } - album = album.copyWith( - provider: AlbumStaticProvider.of(album).copyWith( - items: items, - ), - ); - if (mounted) { - setState(() { - _album = album; - _transformItems(); - initCover(widget.account, album); - }); - } - } - - Future _updateAlbumPostResync( - Album album, List items) async { - return await UpdateAlbumWithActualItems(_c.albumRepo)( - widget.account, album, items); - } - - Future _showSharedAlbumInfoDialog() { - if (!Pref().hasShownSharedAlbumInfoOr(false)) { - return showDialog( - context: context, - builder: (_) => const SharedAlbumInfoDialog(), - barrierDismissible: false, - ); - } else { - return Future.value(); - } - } - - bool get _canRemoveSelection { - if (_album!.albumFile!.isOwned(widget.account.userId) == true) { - return true; - } - final selectedIndexes = - selectedListItems.whereType<_ListItem>().map((e) => e.index).toList(); - final selectedItemsIt = _sortedItems.takeIndex(selectedIndexes); - return selectedItemsIt.any((item) => item.addedBy == widget.account.userId); - } - - static List _getAlbumItemsOf(Album a) => - AlbumStaticProvider.of(a).items; - - late final DiContainer _c; - - Album? _album; - var _sortedItems = []; - var _backingFiles = []; - - final _scrollController = ScrollController(); - double? _itemListMaxExtent; - bool _isDragging = false; - // == null if not drag scrolling - bool? _isDragScrollingDown; - final _editFormKey = GlobalKey(); - Album? _editAlbum; - - late AppEventListener _albumUpdatedListener; - - static const _menuValueDownload = 0; - static const _menuValueFixShares = 1; -} - -enum _SelectionMenuOption { - download, - removeFromAlbum, -} - -@toString -abstract class _ListItem implements SelectableItem, DraggableItem { - const _ListItem({ - required this.index, - this.onTap, - this.onDropBefore, - this.onDropAfter, - this.onDragStarted, - this.onDragEndedAny, - }); - - @override - get isTappable => onTap != null; - - @override - get isSelectable => true; - - @override - get isDraggable => true; - - @override - get staggeredTile => const StaggeredTile.count(1, 1); - - @override - buildDragFeedbackWidget(BuildContext context) => null; - - @override - String toString() => _$toString(); - - final int index; - - @ignore - final VoidCallback? onTap; - @override - @ignore - final DragTargetAccept? onDropBefore; - @override - @ignore - final DragTargetAccept? onDropAfter; - @override - @ignore - final VoidCallback? onDragStarted; - @override - @ignore - final VoidCallback? onDragEndedAny; -} - -abstract class _FileListItem extends _ListItem { - _FileListItem({ - required super.index, - required this.file, - super.onTap, - super.onDropBefore, - super.onDropAfter, - super.onDragStarted, - super.onDragEndedAny, - }); - - final File file; -} - -class _ImageListItem extends _FileListItem { - _ImageListItem({ - required super.index, - required super.file, - required this.account, - required this.previewUrl, - super.onTap, - super.onDropBefore, - super.onDropAfter, - super.onDragStarted, - super.onDragEndedAny, - }); - - @override - buildWidget(BuildContext context) => PhotoListImage( - account: account, - previewUrl: previewUrl, - isGif: file.contentType == "image/gif", - heroKey: flutter_util.getImageHeroTag(file), - ); - - final Account account; - final String previewUrl; -} - -class _VideoListItem extends _FileListItem { - _VideoListItem({ - required super.index, - required super.file, - required this.account, - required this.previewUrl, - super.onTap, - super.onDropBefore, - super.onDropAfter, - super.onDragStarted, - super.onDragEndedAny, - }); - - @override - buildWidget(BuildContext context) => PhotoListVideo( - account: account, - previewUrl: previewUrl, - ); - - final Account account; - final String previewUrl; -} - -class _LabelListItem extends _ListItem { - _LabelListItem({ - required super.index, - required this.text, - super.onDropBefore, - super.onDropAfter, - super.onDragStarted, - super.onDragEndedAny, - }); - - @override - get staggeredTile => const StaggeredTile.extent(99, 56); - - @override - buildWidget(BuildContext context) => PhotoListLabel( - text: text, - ); - - final String text; -} - -class _EditLabelListItem extends _LabelListItem { - _EditLabelListItem({ - required super.index, - required super.text, - required this.onEditPressed, - super.onDropBefore, - super.onDropAfter, - super.onDragStarted, - super.onDragEndedAny, - }); - - @override - buildWidget(BuildContext context) => PhotoListLabelEdit( - text: text, - onEditPressed: onEditPressed, - ); - - @override - buildDragFeedbackWidget(BuildContext context) { - return super.buildWidget(context); - } - - final VoidCallback? onEditPressed; -} - -class _DateListItem extends _ListItem { - const _DateListItem({ - required this.date, - }) : super(index: -1); - - @override - get isSelectable => false; - - @override - get staggeredTile => const StaggeredTile.extent(99, 32); - - @override - buildWidget(BuildContext context) => PhotoListDate( - date: date, - ); - - final DateTime date; -} diff --git a/app/lib/widget/album_browser.g.dart b/app/lib/widget/album_browser.g.dart deleted file mode 100644 index 27993fc2..00000000 --- a/app/lib/widget/album_browser.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'album_browser.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_AlbumBrowserStateNpLog on _AlbumBrowserState { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.album_browser._AlbumBrowserState"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$_ListItemToString on _ListItem { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "_ListItem")} {index: $index}"; - } -} diff --git a/app/lib/widget/album_browser_app_bar.dart b/app/lib/widget/album_browser_app_bar.dart deleted file mode 100644 index 04f8b5c8..00000000 --- a/app/lib/widget/album_browser_app_bar.dart +++ /dev/null @@ -1,141 +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/app_localizations.dart'; -import 'package:nc_photos/cache_manager_util.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/np_api_util.dart'; - -class AlbumBrowserAppBar extends StatelessWidget { - const AlbumBrowserAppBar({ - Key? key, - required this.account, - required this.album, - this.coverPreviewUrl, - this.actions, - }) : super(key: key); - - @override - build(BuildContext context) { - return SliverAppBar( - floating: true, - expandedHeight: 160, - flexibleSpace: FlexibleSpaceBar( - background: _getAppBarCover(context, account, coverPreviewUrl), - title: Text( - album.name, - style: - TextStyle(color: Theme.of(context).appBarTheme.foregroundColor), - ), - ), - actions: actions, - ); - } - - final Account account; - final Album album; - final String? coverPreviewUrl; - final List? actions; -} - -class AlbumBrowserEditAppBar extends StatefulWidget { - const AlbumBrowserEditAppBar({ - Key? key, - required this.account, - required this.album, - this.coverPreviewUrl, - this.actions, - required this.onDonePressed, - required this.onAlbumNameSaved, - }) : super(key: key); - - @override - createState() => _AlbumBrowserEditAppBarState(); - - final Account account; - final Album album; - final String? coverPreviewUrl; - final List? actions; - final VoidCallback? onDonePressed; - final ValueChanged? onAlbumNameSaved; -} - -class _AlbumBrowserEditAppBarState extends State { - @override - initState() { - super.initState(); - _controller = TextEditingController(text: widget.album.name); - } - - @override - build(BuildContext context) { - return SliverAppBar( - floating: true, - expandedHeight: 160, - flexibleSpace: FlexibleSpaceBar( - background: - _getAppBarCover(context, widget.account, widget.coverPreviewUrl), - title: TextFormField( - controller: _controller, - decoration: InputDecoration( - hintText: L10n.global().nameInputHint, - ), - validator: (_) { - // use _controller.text here because the value might be wrong if - // user scrolled the app bar off screen - if (_controller.text.isNotEmpty == true) { - return null; - } else { - return L10n.global().nameInputInvalidEmpty; - } - }, - onSaved: (_) { - widget.onAlbumNameSaved?.call(_controller.text); - }, - style: Theme.of(context).textTheme.titleLarge!.copyWith( - color: Theme.of(context).appBarTheme.foregroundColor, - ), - ), - ), - leading: IconButton( - icon: const Icon(Icons.check), - color: Theme.of(context).colorScheme.primary, - tooltip: L10n.global().doneButtonTooltip, - onPressed: widget.onDonePressed, - ), - actions: widget.actions, - ); - } - - late TextEditingController _controller; -} - -Widget? _getAppBarCover( - BuildContext context, Account account, String? coverPreviewUrl) { - try { - if (coverPreviewUrl != 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: coverPreviewUrl, - httpHeaders: { - "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), - }, - filterQuality: FilterQuality.high, - errorWidget: (context, url, error) { - // just leave it empty - return Container(); - }, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, - ), - ), - ); - } - } catch (_) {} - return null; -} diff --git a/app/lib/widget/album_browser_mixin.dart b/app/lib/widget/album_browser_mixin.dart deleted file mode 100644 index 3ec6fd65..00000000 --- a/app/lib/widget/album_browser_mixin.dart +++ /dev/null @@ -1,262 +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/api/api_util.dart' as api_util; -import 'package:nc_photos/app_localizations.dart'; -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/cover_provider.dart'; -import 'package:nc_photos/entity/album/data_source.dart'; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/notified_action.dart'; -import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; -import 'package:nc_photos/use_case/import_pending_shared_album.dart'; -import 'package:nc_photos/use_case/update_album.dart'; -import 'package:nc_photos/widget/album_browser_app_bar.dart'; -import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util; -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/zoom_menu_button.dart'; -import 'package:np_codegen/np_codegen.dart'; - -part 'album_browser_mixin.g.dart'; - -@npLog -mixin AlbumBrowserMixin - on SelectableItemStreamListMixin { - @override - initState() { - super.initState(); - _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); - } - - @protected - void initCover(Account account, Album album) { - try { - final coverFile = album.coverProvider.getCover(album); - _coverPreviewUrl = api_util.getFilePreviewUrl(account, coverFile!, - width: k.coverSize, height: k.coverSize, isKeepAspectRatio: false); - } catch (_) {} - } - - @protected - Widget buildNormalAppBar( - BuildContext context, - Account account, - Album album, { - List? actions, - List> Function(BuildContext)? menuItemBuilder, - void Function(int)? onSelectedMenuItem, - }) { - final menuItems = [ - if (canEdit) - PopupMenuItem( - value: _menuValueEdit, - child: Text(L10n.global().editAlbumMenuLabel), - ), - if (canEdit && album.coverProvider is AlbumManualCoverProvider) - PopupMenuItem( - value: _menuValueUnsetCover, - child: Text(L10n.global().unsetAlbumCoverTooltip), - ), - ]; - return AlbumBrowserAppBar( - account: account, - album: album, - coverPreviewUrl: _coverPreviewUrl, - actions: [ - ZoomMenuButton( - initialZoom: _thumbZoomLevel, - minZoom: 0, - maxZoom: 2, - onZoomChanged: (value) { - setState(() { - _thumbZoomLevel = value.round(); - }); - Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel); - }, - ), - if (album.albumFile?.path.startsWith( - remote_storage_util.getRemotePendingSharedAlbumsDir(account)) == - true) - IconButton( - onPressed: () => _onAddToCollectionPressed(context, account, album), - icon: const Icon(Icons.library_add), - tooltip: L10n.global().addToCollectionsViewTooltip, - ), - ...(actions ?? []), - if (menuItemBuilder != null || menuItems.isNotEmpty) - PopupMenuButton( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) => [ - ...menuItems, - ...(menuItemBuilder?.call(context) ?? []), - ], - onSelected: (option) => _onMenuOptionSelected( - option, account, album, onSelectedMenuItem), - ), - ], - ); - } - - @protected - Widget buildSelectionAppBar(BuildContext context, List actions) { - return SelectionAppBar( - count: selectedListItems.length, - onClosePressed: () { - setState(() { - clearSelectedItems(); - }); - }, - actions: actions, - ); - } - - @protected - Widget buildEditAppBar( - BuildContext context, - Account account, - Album album, { - List? actions, - }) { - return AlbumBrowserEditAppBar( - account: account, - album: album, - coverPreviewUrl: _coverPreviewUrl, - actions: actions, - onDonePressed: () { - if (validateEditMode()) { - setState(() { - _isEditMode = false; - }); - doneEditMode(); - } - }, - onAlbumNameSaved: (value) { - _editFormValue.name = value; - }, - ); - } - - @protected - bool get isEditMode => _isEditMode; - - @protected - bool get canEdit => true; - - @protected - @mustCallSuper - void enterEditMode() {} - - /// Validates the pending modifications - @protected - bool validateEditMode() => true; - - @protected - void doneEditMode() {} - - /// Return a new album with the edits - @protected - Album makeEdited(Album album) { - return album.copyWith( - name: _editFormValue.name, - ); - } - - @protected - int get thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); - - void _onMenuOptionSelected(int option, Account account, Album album, - void Function(int)? onSelectedMenuItem) { - if (option >= 0) { - onSelectedMenuItem?.call(option); - } else { - switch (option) { - case _menuValueEdit: - _onAppBarEditPressed(album); - break; - - case _menuValueUnsetCover: - _onUnsetCoverPressed(account, album); - break; - - default: - _log.shout("[_onMenuOptionSelected] Unknown value: $option"); - break; - } - } - } - - void _onAppBarEditPressed(Album album) { - setState(() { - _isEditMode = true; - enterEditMode(); - _editFormValue = _EditFormValue(); - }); - } - - Future _onUnsetCoverPressed(Account account, Album album) async { - _log.info("[_onUnsetCoverPressed] Unset album cover for '${album.name}'"); - final c = KiwiContainer().resolve(); - try { - await NotifiedAction( - () async { - final albumRepo = AlbumRepo(AlbumCachedDataSource(c)); - await UpdateAlbum(albumRepo)( - account, - album.copyWith( - coverProvider: const AlbumAutoCoverProvider(), - )); - }, - L10n.global().unsetAlbumCoverProcessingNotification, - L10n.global().unsetAlbumCoverSuccessNotification, - failureText: L10n.global().setCollectionCoverFailureNotification, - )(); - } catch (e, stackTrace) { - _log.shout( - "[_onUnsetCoverPressed] Failed while updating album", e, stackTrace); - } - } - - Future _onAddToCollectionPressed( - BuildContext context, Account account, Album album) async { - Album? newAlbum; - try { - await NotifiedAction( - () async { - newAlbum = await ImportPendingSharedAlbum( - KiwiContainer().resolve())(account, album); - }, - L10n.global().addToCollectionsViewProcessingNotification(album.name), - L10n.global().addToCollectionsViewSuccessNotification(album.name), - )(); - } catch (e, stackTrace) { - _log.shout( - "[_onAddToCollectionPressed] Failed while _onAddToCollectionPressed: ${logFilename(album.albumFile?.path)}", - e, - stackTrace); - } - if (newAlbum != null && mounted) { - album_browser_util.pushReplacement(context, account, newAlbum!); - } - } - - String? _coverPreviewUrl; - var _thumbZoomLevel = 0; - - var _isEditMode = false; - var _editFormValue = _EditFormValue(); - - static const _menuValueEdit = -1; - static const _menuValueUnsetCover = -2; -} - -class _EditFormValue { - late String name; -} diff --git a/app/lib/widget/album_browser_mixin.g.dart b/app/lib/widget/album_browser_mixin.g.dart deleted file mode 100644 index 0965e136..00000000 --- a/app/lib/widget/album_browser_mixin.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'album_browser_mixin.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$AlbumBrowserMixinNpLog on AlbumBrowserMixin { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.album_browser_mixin.AlbumBrowserMixin"); -} diff --git a/app/lib/widget/album_browser_util.dart b/app/lib/widget/album_browser_util.dart deleted file mode 100644 index 11bd502d..00000000 --- a/app/lib/widget/album_browser_util.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/widget/album_browser.dart'; - -/// Push the corresponding browser route for this album -void push(BuildContext context, Account account, Album album) { - if (album.provider is AlbumStaticProvider) { - Navigator.of(context).pushNamed(AlbumBrowser.routeName, - arguments: AlbumBrowserArguments(account, album)); - } -} - -/// Push the corresponding browser route for this album and replace the current -/// route -void pushReplacement(BuildContext context, Account account, Album album) { - if (album.provider is AlbumStaticProvider) { - Navigator.of(context).pushReplacementNamed(AlbumBrowser.routeName, - arguments: AlbumBrowserArguments(account, album)); - } -} diff --git a/app/lib/widget/draggable_item_list_mixin.dart b/app/lib/widget/draggable_item_list_mixin.dart deleted file mode 100644 index 21c909b2..00000000 --- a/app/lib/widget/draggable_item_list_mixin.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:nc_photos/widget/draggable.dart' as my; -import 'package:nc_photos/widget/measurable_item_list.dart'; - -abstract class DraggableItem { - Widget buildWidget(BuildContext context); - - /// The widget to show under the pointer when a drag is under way. - /// - /// Return null if you wish to just use the same widget as display - Widget? buildDragFeedbackWidget(BuildContext context) => null; - - bool get isDraggable => false; - DragTargetAccept? get onDropBefore => null; - DragTargetAccept? get onDropAfter => null; - VoidCallback? get onDragStarted => null; - VoidCallback? get onDragEndedAny => null; - StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); -} - -mixin DraggableItemListMixin on State { - @protected - Widget buildDraggableItemList({ - required double maxCrossAxisExtent, - ValueChanged? onMaxExtentChanged, - }) { - _maxCrossAxisExtent = maxCrossAxisExtent; - return MeasurableItemList( - maxCrossAxisExtent: maxCrossAxisExtent, - itemCount: _items.length, - itemBuilder: _buildItem, - staggeredTileBuilder: (index) => _items[index].staggeredTile, - onMaxExtentChanged: onMaxExtentChanged, - ); - } - - @protected - set draggableItemList(List newItems) { - _items = newItems; - } - - Widget _buildItem(BuildContext context, int index) { - final item = _items[index]; - return my.Draggable( - data: item, - feedback: item.buildDragFeedbackWidget(context), - onDropBefore: item.onDropBefore, - onDropAfter: item.onDropAfter, - onDragStarted: item.onDragStarted, - onDragEndedAny: item.onDragEndedAny, - feedbackSize: Size(_maxCrossAxisExtent * .65, _maxCrossAxisExtent * .65), - child: item.buildWidget(context), - ); - } - - var _items = []; - late double _maxCrossAxisExtent; -} diff --git a/app/lib/widget/share_album_dialog.dart b/app/lib/widget/share_album_dialog.dart deleted file mode 100644 index e406cc80..00000000 --- a/app/lib/widget/share_album_dialog.dart +++ /dev/null @@ -1,346 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; -import 'package:kiwi/kiwi.dart'; -import 'package:logging/logging.dart'; -import 'package:mutex/mutex.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/async_util.dart' as async_util; -import 'package:nc_photos/bloc/list_sharee.dart'; -import 'package:nc_photos/bloc/search_suggestion.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/sharee.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/album/share_album_with_user.dart'; -import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; -import 'package:nc_photos/widget/album_share_outlier_browser.dart'; -import 'package:nc_photos/widget/dialog_scaffold.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:np_common/ci_string.dart'; - -part 'share_album_dialog.g.dart'; - -class ShareAlbumDialog extends StatefulWidget { - ShareAlbumDialog({ - Key? key, - required this.account, - required this.album, - }) : assert(album.albumFile != null), - super(key: key); - - @override - createState() => _ShareAlbumDialogState(); - - final Account account; - final Album album; -} - -@npLog -class _ShareAlbumDialogState extends State { - _ShareAlbumDialogState() { - final c = KiwiContainer().resolve(); - assert(require(c)); - _c = c; - } - - static bool require(DiContainer c) => - DiContainer.has(c, DiType.albumRepo) && - DiContainer.has(c, DiType.shareRepo); - - @override - initState() { - super.initState(); - _album = widget.album; - _items = _album.shares - ?.map((s) => - _ShareItem(s.userId, s.displayName ?? s.userId.toString())) - .toList() ?? - []; - _initBloc(); - } - - @override - build(BuildContext context) { - return DialogScaffold( - canPop: _processingSharee.isEmpty, - body: BlocListener( - bloc: _shareeBloc, - listener: _onShareeStateChange, - child: Builder( - builder: _buildContent, - ), - ), - ); - } - - Widget _buildContent(BuildContext context) { - return SimpleDialog( - title: Text(L10n.global().shareAlbumDialogTitle), - children: [ - ..._items.map((i) => _buildItem(context, i)), - _buildCreateShareItem(context), - ], - ); - } - - Widget _buildItem(BuildContext context, _ShareItem share) { - final isProcessing = _processingSharee.any((s) => s == share.shareWith); - final Widget trailing; - if (isProcessing) { - trailing = const Padding( - padding: EdgeInsetsDirectional.only(end: 12), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ), - ); - } else { - trailing = Checkbox( - value: true, - onChanged: (_) {}, - ); - } - return SimpleDialogOption( - onPressed: isProcessing ? () {} : () => _onShareItemPressed(share), - child: ListTile( - title: Text(share.displayName), - subtitle: Text(share.shareWith.toString()), - // pass through the tap event - trailing: IgnorePointer( - child: trailing, - ), - ), - ); - } - - Widget _buildCreateShareItem(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - child: TypeAheadField( - textFieldConfiguration: TextFieldConfiguration( - controller: _searchController, - decoration: InputDecoration( - hintText: L10n.global().addUserInputHint, - ), - ), - suggestionsCallback: _onSearch, - itemBuilder: (context, suggestion) => ListTile( - title: Text(suggestion.label), - subtitle: Text(suggestion.shareWith.toString()), - ), - onSuggestionSelected: _onSearchSuggestionSelected, - hideOnEmpty: true, - hideOnLoading: true, - autoFlipDirection: true, - ), - ); - } - - void _onShareeStateChange(BuildContext context, ListShareeBlocState state) { - if (state is ListShareeBlocSuccess) { - _transformShareeItems(state.items); - } else if (state is ListShareeBlocFailure) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } - } - - Future _onShareItemPressed(_ShareItem share) async { - setState(() { - _processingSharee.add(share.shareWith); - }); - try { - if (await _removeShare(share)) { - if (mounted) { - setState(() { - _items.remove(share); - _onShareItemListUpdated(); - }); - } - } - } finally { - if (mounted) { - setState(() { - _processingSharee.remove(share.shareWith); - }); - } - } - } - - Future> _onSearch(String pattern) async { - _suggestionBloc.add(SearchSuggestionBlocSearchEvent(pattern.toCi())); - await Future.delayed(const Duration(milliseconds: 500)); - await async_util - .wait(() => _suggestionBloc.state is! SearchSuggestionBlocLoading); - if (_suggestionBloc.state is SearchSuggestionBlocSuccess) { - return _suggestionBloc.state.results; - } else { - return []; - } - } - - Future _onSearchSuggestionSelected(Sharee sharee) async { - _searchController.clear(); - final item = _ShareItem(sharee.shareWith, sharee.label); - var isGood = false; - setState(() { - _items.add(item); - _onShareItemListUpdated(); - _processingSharee.add(sharee.shareWith); - }); - try { - isGood = await _createShare(sharee); - } finally { - if (mounted) { - setState(() { - if (!isGood) { - _items.remove(item); - _onShareItemListUpdated(); - } - _processingSharee.remove(sharee.shareWith); - }); - } - } - } - - void _onShareItemListUpdated() { - if (_shareeBloc.state is ListShareeBlocSuccess) { - _transformShareeItems(_shareeBloc.state.items); - } - } - - void _onFixPressed() { - Navigator.of(context).pushNamed(AlbumShareOutlierBrowser.routeName, - arguments: AlbumShareOutlierBrowserArguments(widget.account, _album)); - } - - void _transformShareeItems(List sharees) { - final candidates = sharees - .where((s) => - s.shareWith != widget.account.userId && - // remove users already shared with - !_items.any((i) => i.shareWith == s.shareWith)) - .toList(); - _suggestionBloc - .add(SearchSuggestionBlocUpdateItemsEvent(candidates)); - } - - Future _createShare(Sharee sharee) async { - var hasFailure = false; - try { - _album = await _editMutex.protect(() async { - return await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)( - widget.account, - _album, - sharee, - onShareFileFailed: (_, __, ___) { - hasFailure = true; - }, - ); - }); - } catch (e, stackTrace) { - _log.shout( - "[_createShare] Failed while ShareAlbumWithUser", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - return false; - } - SnackBarManager().showSnackBar(SnackBar( - content: Text(hasFailure - ? L10n.global() - .shareAlbumSuccessWithErrorNotification(sharee.shareWith) - : L10n.global().shareAlbumSuccessNotification(sharee.shareWith)), - action: hasFailure - ? SnackBarAction( - label: L10n.global().fixButtonLabel, - onPressed: _onFixPressed, - ) - : null, - duration: k.snackBarDurationNormal, - )); - return true; - } - - Future _removeShare(_ShareItem share) async { - var hasFailure = false; - try { - _album = await _editMutex.protect(() async { - return await UnshareAlbumWithUser( - KiwiContainer().resolve())( - widget.account, - _album, - share.shareWith, - onUnshareFileFailed: (_, __, ___) { - hasFailure = true; - }, - ); - }); - } catch (e, stackTrace) { - _log.shout( - "[_removeShare] Failed while UnshareAlbumWithUser", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), - duration: k.snackBarDurationNormal, - )); - return false; - } - SnackBarManager().showSnackBar(SnackBar( - content: Text(hasFailure - ? L10n.global() - .unshareAlbumSuccessWithErrorNotification(share.shareWith) - : L10n.global().unshareAlbumSuccessNotification(share.shareWith)), - action: hasFailure - ? SnackBarAction( - label: L10n.global().fixButtonLabel, - onPressed: _onFixPressed, - ) - : null, - duration: k.snackBarDurationNormal, - )); - return true; - } - - Future _initBloc() async { - if (_shareeBloc.state is ListShareeBlocSuccess) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _onShareeStateChange(context, _shareeBloc.state); - }); - } - }); - } else { - _log.info("[_initBloc] Initialize bloc"); - _shareeBloc.add(ListShareeBlocQuery(widget.account)); - } - } - - late final DiContainer _c; - - late final _shareeBloc = ListShareeBloc.of(widget.account); - final _suggestionBloc = SearchSuggestionBloc( - itemToKeywords: (item) => [item.shareWith, item.label.toCi()], - ); - - late Album _album; - final _editMutex = Mutex(); - late final List<_ShareItem> _items; - final _processingSharee = []; - final _searchController = TextEditingController(); -} - -class _ShareItem { - _ShareItem(this.shareWith, this.displayName); - - final CiString shareWith; - final String displayName; -} diff --git a/app/lib/widget/share_album_dialog.g.dart b/app/lib/widget/share_album_dialog.g.dart deleted file mode 100644 index 9a649177..00000000 --- a/app/lib/widget/share_album_dialog.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'share_album_dialog.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$_ShareAlbumDialogStateNpLog on _ShareAlbumDialogState { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("widget.share_album_dialog._ShareAlbumDialogState"); -} From c58dca99cc98bcae596e0916ae2a9ff57a15be8f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 02:08:21 +0800 Subject: [PATCH 52/67] Regression: fix share button in shared album --- .../widget/collection_browser/app_bar.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 4f0d1ed7..bba5d0fc 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -64,6 +64,11 @@ class _AppBar extends StatelessWidget { child: Text(L10n.global().exportCollectionTooltip), ), ], + if (state.collection.contentProvider is CollectionAlbumProvider) + PopupMenuItem( + value: _MenuOption.albumFixShare, + child: Text(L10n.global().fixSharesTooltip), + ), ], onSelected: (option) { _onMenuSelected(context, option); @@ -103,6 +108,9 @@ class _AppBar extends StatelessWidget { case _MenuOption.export: _onExportSelected(context); break; + case _MenuOption.albumFixShare: + _onAlbumFixShareSelected(context); + break; } } @@ -121,6 +129,16 @@ class _AppBar extends StatelessWidget { } } + void _onAlbumFixShareSelected(BuildContext context) { + final bloc = context.read<_Bloc>(); + final collection = bloc.state.collection; + final album = (collection.contentProvider as CollectionAlbumProvider).album; + Navigator.of(context).pushNamed( + AlbumShareOutlierBrowser.routeName, + arguments: AlbumShareOutlierBrowserArguments(bloc.account, album), + ); + } + Future _onSharePressed(BuildContext context) async { final bloc = context.read<_Bloc>(); await showDialog( @@ -426,6 +444,7 @@ enum _MenuOption { unsetCover, download, export, + albumFixShare, } enum _SelectionMenuOption { From f05698c040b42079e86120c74079b565d376dcdf Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 02:09:23 +0800 Subject: [PATCH 53/67] Regression: show info dialog when first opening a shared album --- app/lib/widget/collection_browser.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index dafa3b0a..c0ed8a00 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -25,6 +25,7 @@ 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/content_provider/album.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'; @@ -43,6 +44,7 @@ import 'package:nc_photos/use_case/archive_file.dart'; import 'package:nc_photos/use_case/collection/import_pending_shared_collection.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/remove.dart'; +import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/asset_icon.dart'; import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/draggable_item_list.dart'; @@ -56,6 +58,7 @@ 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/share_collection_dialog.dart'; +import 'package:nc_photos/widget/shared_album_info_dialog.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'; @@ -128,6 +131,11 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> void initState() { super.initState(); _bloc.add(const _LoadItems()); + + if (_bloc.state.collection.shares.isNotEmpty && + _bloc.state.collection.contentProvider is CollectionAlbumProvider) { + _showSharedAlbumInfoDialog(); + } } @override @@ -323,6 +331,17 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> } } + Future _showSharedAlbumInfoDialog() async { + final pref = KiwiContainer().resolve().pref; + if (!pref.hasShownSharedAlbumInfoOr(false)) { + return showDialog( + context: context, + builder: (_) => const SharedAlbumInfoDialog(), + barrierDismissible: false, + ); + } + } + late final _bloc = context.read<_Bloc>(); final _scrollController = ScrollController(); bool? _isDragScrollingDown; From 66c5b6608b910b1d1637eb45592d37482906f45b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 16:00:18 +0800 Subject: [PATCH 54/67] Tweak log --- np_api/lib/src/api.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/np_api/lib/src/api.dart b/np_api/lib/src/api.dart index f28bc238..fac28af3 100644 --- a/np_api/lib/src/api.dart +++ b/np_api/lib/src/api.dart @@ -60,6 +60,7 @@ class Api { } else if (bodyBytes != null) { req.bodyBytes = bodyBytes; } + _log.finer(req.url); final response = await http.Response.fromStream(await http.Client().send(req)); if (!isHttpStatusGood(response.statusCode)) { From 360c74ce95df3674451509eb71f4a2bdeb08784c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 19:07:07 +0800 Subject: [PATCH 55/67] Tidy up error handling in blocs --- app/lib/widget/collection_browser/bloc.dart | 14 +++++++++++ app/lib/widget/collection_picker/bloc.dart | 16 +++++++++++++ .../widget/export_collection_dialog.g.dart | 7 ++++++ .../widget/export_collection_dialog/bloc.dart | 24 +++++++++++++++++-- .../export_collection_dialog/state_event.dart | 11 +++++++++ app/lib/widget/file_sharer.g.dart | 7 ++++++ app/lib/widget/file_sharer/bloc.dart | 22 +++++++++++++++++ app/lib/widget/file_sharer/state_event.dart | 11 +++++++++ app/lib/widget/home_collections.g.dart | 7 ++++++ app/lib/widget/home_collections/bloc.dart | 21 ++++++++++++++++ .../widget/home_collections/state_event.dart | 11 +++++++++ app/lib/widget/new_collection_dialog.dart | 12 ++++++++++ app/lib/widget/new_collection_dialog.g.dart | 21 +++++++++++++--- .../widget/new_collection_dialog/bloc.dart | 22 +++++++++++++++++ .../new_collection_dialog/state_event.dart | 14 +++++++++++ 15 files changed, 215 insertions(+), 5 deletions(-) diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index ad0567c5..534a9420 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -75,6 +75,19 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { return super.close(); } + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + bool isCollectionCapabilityPermitted(CollectionCapability capability) { return CollectionAdapter.of(_c, account, state.collection) .isPermitted(capability); @@ -470,6 +483,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { StreamSubscription? _collectionControllerSubscription; StreamSubscription? _itemsControllerSubscription; + var _isHandlingError = false; } class _TransformResult { diff --git a/app/lib/widget/collection_picker/bloc.dart b/app/lib/widget/collection_picker/bloc.dart index 38ef79cc..192d044b 100644 --- a/app/lib/widget/collection_picker/bloc.dart +++ b/app/lib/widget/collection_picker/bloc.dart @@ -9,12 +9,26 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_LoadCollections>(_onLoad); on<_TransformItems>(_onTransformItems); on<_SelectCollection>(_onSelectCollection); + on<_SetError>(_onSetError); } @override String get tag => _log.fullName; + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + Future _onLoad(_LoadCollections ev, Emitter<_State> emit) async { _log.info(ev); return emit.forEach( @@ -60,4 +74,6 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { final Account account; final CollectionsController controller; + + var _isHandlingError = false; } diff --git a/app/lib/widget/export_collection_dialog.g.dart b/app/lib/widget/export_collection_dialog.g.dart index 9cae4350..a3a625cf 100644 --- a/app/lib/widget/export_collection_dialog.g.dart +++ b/app/lib/widget/export_collection_dialog.g.dart @@ -111,3 +111,10 @@ extension _$_SubmitFormToString on _SubmitForm { return "_SubmitForm {}"; } } + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/export_collection_dialog/bloc.dart b/app/lib/widget/export_collection_dialog/bloc.dart index 6d2abed7..6e8faa3c 100644 --- a/app/lib/widget/export_collection_dialog/bloc.dart +++ b/app/lib/widget/export_collection_dialog/bloc.dart @@ -9,6 +9,21 @@ class _Bloc extends Bloc<_Event, _State> { required this.items, }) : super(_State.init()) { on<_FormEvent>(_onFormEvent); + + on<_SetError>(_onSetError); + } + + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); } Future _onFormEvent(_FormEvent ev, Emitter<_State> emit) async { @@ -49,15 +64,20 @@ class _Bloc extends Bloc<_Event, _State> { break; } emit(state.copyWith(result: result)); - } catch (e, stackTrace) { - _log.severe("[_onSubmitForm] Failed while exporting", e, stackTrace); } finally { emit(state.copyWith(isExporting: false)); } } + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + final Account account; final CollectionsController collectionsController; final Collection collection; final List items; + + var _isHandlingError = false; } diff --git a/app/lib/widget/export_collection_dialog/state_event.dart b/app/lib/widget/export_collection_dialog/state_event.dart index bd56d8e2..be231805 100644 --- a/app/lib/widget/export_collection_dialog/state_event.dart +++ b/app/lib/widget/export_collection_dialog/state_event.dart @@ -69,3 +69,14 @@ class _SubmitForm extends _FormEvent { @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; +} diff --git a/app/lib/widget/file_sharer.g.dart b/app/lib/widget/file_sharer.g.dart index 161428fc..fcba4050 100644 --- a/app/lib/widget/file_sharer.g.dart +++ b/app/lib/widget/file_sharer.g.dart @@ -210,3 +210,10 @@ extension _$_SetPasswordLinkDetailsToString on _SetPasswordLinkDetails { return "_SetPasswordLinkDetails {albumName: $albumName, password: $password}"; } } + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/file_sharer/bloc.dart b/app/lib/widget/file_sharer/bloc.dart index 26cb3744..51f0194c 100644 --- a/app/lib/widget/file_sharer/bloc.dart +++ b/app/lib/widget/file_sharer/bloc.dart @@ -12,11 +12,26 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_SetResult>(_onSetResult); on<_SetPublicLinkDetails>(_onSetPublicLinkDetails); on<_SetPasswordLinkDetails>(_onSetPasswordLinkDetails); + + on<_SetError>(_onSetError); } @override String get tag => _log.fullName; + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + Future _onSetMethod(_SetMethod ev, Emitter<_State> emit) async { _log.info("$ev"); emit(state.copyWith(method: ev.method)); @@ -49,6 +64,11 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { return _doShareLink(emit, albumName: ev.albumName, password: ev.password); } + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + Future _doShareFile(Emitter<_State> emit) async { assert(platform_k.isAndroid); emit(state.copyWith( @@ -206,4 +226,6 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { final DiContainer _c; final Account account; final List files; + + var _isHandlingError = false; } diff --git a/app/lib/widget/file_sharer/state_event.dart b/app/lib/widget/file_sharer/state_event.dart index 00a78aa9..ef2e399d 100644 --- a/app/lib/widget/file_sharer/state_event.dart +++ b/app/lib/widget/file_sharer/state_event.dart @@ -135,3 +135,14 @@ class _SetPasswordLinkDetails implements _Event { final String? albumName; final String password; } + +@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/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index 49d5cde3..b70f5db0 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -148,3 +148,10 @@ extension _$_SetCollectionSortToString on _SetCollectionSort { return "_SetCollectionSort {sort: ${sort.name}}"; } } + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/home_collections/bloc.dart b/app/lib/widget/home_collections/bloc.dart index 15d63736..76689862 100644 --- a/app/lib/widget/home_collections/bloc.dart +++ b/app/lib/widget/home_collections/bloc.dart @@ -17,6 +17,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_UpdateCollectionSort>(_onUpdateCollectionSort); on<_SetCollectionSort>(_onSetCollectionSort); + on<_SetError>(_onSetError); + _homeAlbumsSortSubscription = prefController.homeAlbumsSort.distinct().listen((event) { add(_UpdateCollectionSort(collection_util.CollectionSort.values[event])); @@ -32,6 +34,19 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { @override String get tag => _log.fullName; + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + Future _onLoad(_LoadCollections ev, Emitter<_State> emit) async { _log.info("[_onLoad] $ev"); return emit.forEach( @@ -90,6 +105,11 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { prefController.setHomeAlbumsSort(ev.sort.index); } + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + List<_Item> _transformCollections( List collections, collection_util.CollectionSort sort, @@ -103,4 +123,5 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { final PrefController prefController; StreamSubscription? _homeAlbumsSortSubscription; + var _isHandlingError = false; } diff --git a/app/lib/widget/home_collections/state_event.dart b/app/lib/widget/home_collections/state_event.dart index b9190909..08f22632 100644 --- a/app/lib/widget/home_collections/state_event.dart +++ b/app/lib/widget/home_collections/state_event.dart @@ -111,3 +111,14 @@ class _SetCollectionSort implements _Event { final collection_util.CollectionSort sort; } + +@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/new_collection_dialog.dart b/app/lib/widget/new_collection_dialog.dart index 113a4c29..a1e2ea91 100644 --- a/app/lib/widget/new_collection_dialog.dart +++ b/app/lib/widget/new_collection_dialog.dart @@ -18,6 +18,7 @@ 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_event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/object_extension.dart'; @@ -88,6 +89,17 @@ class _WrappedNewCollectionDialogState previous.result != current.result && current.result != null, listener: _onResult, ), + 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: BlocBuilder<_Bloc, _State>( buildWhen: (previous, current) => diff --git a/app/lib/widget/new_collection_dialog.g.dart b/app/lib/widget/new_collection_dialog.g.dart index 3f38591d..c6813ff7 100644 --- a/app/lib/widget/new_collection_dialog.g.dart +++ b/app/lib/widget/new_collection_dialog.g.dart @@ -43,7 +43,11 @@ extension $_FormValueCopyWith on _FormValue { } abstract class $_StateCopyWithWorker { - _State call({_FormValue? formValue, Collection? result, bool? showDialog}); + _State call( + {_FormValue? formValue, + Collection? result, + bool? showDialog, + ExceptionEvent? error}); } class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { @@ -51,12 +55,16 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { @override _State call( - {dynamic formValue, dynamic result = copyWithNull, dynamic showDialog}) { + {dynamic formValue, + dynamic result = copyWithNull, + dynamic showDialog, + dynamic error = copyWithNull}) { 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); + showDialog: showDialog as bool? ?? that.showDialog, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); } final _State that; @@ -132,3 +140,10 @@ extension _$_HideDialogToString on _HideDialog { return "_HideDialog {}"; } } + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/new_collection_dialog/bloc.dart b/app/lib/widget/new_collection_dialog/bloc.dart index 1f674960..cf827111 100644 --- a/app/lib/widget/new_collection_dialog/bloc.dart +++ b/app/lib/widget/new_collection_dialog/bloc.dart @@ -11,6 +11,21 @@ class _Bloc extends Bloc<_Event, _State> { )) { on<_FormEvent>(_onFormEvent); on<_HideDialog>(_onHideDialog); + + on<_SetError>(_onSetError); + } + + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); } void _onFormEvent(_FormEvent ev, Emitter<_State> emit) { @@ -68,6 +83,11 @@ class _Bloc extends Bloc<_Event, _State> { .setLastNewCollectionType(state.formValue.provider.index)); } + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + CollectionContentProvider _buildProvider() { switch (state.formValue.provider) { case _ProviderOption.appAlbum: @@ -115,4 +135,6 @@ class _Bloc extends Bloc<_Event, _State> { } final Account account; + + var _isHandlingError = false; } diff --git a/app/lib/widget/new_collection_dialog/state_event.dart b/app/lib/widget/new_collection_dialog/state_event.dart index 6fcdefd9..21b96cc8 100644 --- a/app/lib/widget/new_collection_dialog/state_event.dart +++ b/app/lib/widget/new_collection_dialog/state_event.dart @@ -22,6 +22,7 @@ class _State { required this.formValue, this.result, required this.showDialog, + this.error, }); factory _State.init({ @@ -55,6 +56,8 @@ class _State { final _FormValue formValue; final Collection? result; final bool showDialog; + + final ExceptionEvent? error; } abstract class _Event { @@ -120,3 +123,14 @@ class _HideDialog extends _Event { @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; +} From 9a5f8c6daf0940f9a82b4145520cb1dfc80de58b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 19:47:07 +0800 Subject: [PATCH 56/67] Make string localizable --- app/lib/l10n/app_en.arb | 1 + app/lib/l10n/untranslated-messages.txt | 31 +++++++++++++++++--------- app/lib/widget/home_collections.dart | 5 +++-- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 455791c0..a926e9fd 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1462,6 +1462,7 @@ "description": "Server-side albums that are available in Nextcloud 25+" }, "createCollectionDialogNextcloudAlbumDescription": "Server-side album, require Nextcloud 25 or above", + "removeCollectionsFailedNotification": "Failed to remove some collections", "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index bbe4bf41..3f503d26 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -9,7 +9,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "de": [ @@ -219,6 +220,7 @@ "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification", "errorAlbumDowngrade" ], @@ -322,7 +324,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "es": [ @@ -335,7 +338,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "fi": [ @@ -347,7 +351,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "fr": [ @@ -470,7 +475,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "pl": [ @@ -607,7 +613,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "pt": [ @@ -620,7 +627,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "ru": [ @@ -739,7 +747,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "zh": [ @@ -858,7 +867,8 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ], "zh_Hant": [ @@ -977,6 +987,7 @@ "exportCollectionTooltip", "exportCollectionDialogTitle", "createCollectionDialogNextcloudAlbumLabel", - "createCollectionDialogNextcloudAlbumDescription" + "createCollectionDialogNextcloudAlbumDescription", + "removeCollectionsFailedNotification" ] } diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index e6422050..d1692355 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -113,8 +113,9 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> previous.removeError != current.removeError, listener: (context, state) { if (state.removeError != null && isPageVisible()) { - SnackBarManager().showSnackBar(const SnackBar( - content: Text('Failed to remove some collections'), + SnackBarManager().showSnackBar(SnackBar( + content: + Text(L10n.global().removeCollectionsFailedNotification), duration: k.snackBarDurationNormal, )); } From 00961c48aa609a719629a1cbb441e3dff190770a Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 19:49:27 +0800 Subject: [PATCH 57/67] Fix error handling in share collection dialog --- .../controller/collections_controller.dart | 11 +++---- app/lib/exception.dart | 23 ++++++++++++-- app/lib/widget/share_collection_dialog.dart | 16 ++++++++-- .../widget/share_collection_dialog/bloc.dart | 30 +++++++++++-------- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 5bda97f2..95f9647b 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -24,7 +24,6 @@ import 'package:nc_photos/use_case/collection/remove_collections.dart'; import 'package:nc_photos/use_case/collection/share_collection.dart'; import 'package:nc_photos/use_case/collection/unshare_collection.dart'; import 'package:np_codegen/np_codegen.dart'; -import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:rxdart/rxdart.dart'; @@ -229,21 +228,22 @@ class CollectionsController { _updateCollection(newCollection!); } if (result == CollectionShareResult.partial) { - _dataStreamController.addError(const CollectionPartialShareException()); + _dataStreamController + .addError(CollectionPartialShareException(sharee.shareWith.raw)); } } catch (e, stackTrace) { _dataStreamController.addError(e, stackTrace); } } - Future unshare(Collection collection, CiString userId) async { + Future unshare(Collection collection, CollectionShare share) async { try { Collection? newCollection; final result = await _mutex.protect(() async { return await UnshareCollection(_c)( account, collection, - userId, + share.userId, onCollectionUpdated: (c) { newCollection = c; }, @@ -253,7 +253,8 @@ class CollectionsController { _updateCollection(newCollection!); } if (result == CollectionShareResult.partial) { - _dataStreamController.addError(const CollectionPartialShareException()); + _dataStreamController + .addError(CollectionPartialUnshareException(share.username)); } } catch (e, stackTrace) { _dataStreamController.addError(e, stackTrace); diff --git a/app/lib/exception.dart b/app/lib/exception.dart index 5f7444b7..255f770e 100644 --- a/app/lib/exception.dart +++ b/app/lib/exception.dart @@ -1,4 +1,4 @@ -import 'package:np_api/np_api.dart'; +import 'package:np_api/np_api.dart' as api; class CacheNotFoundException implements Exception { const CacheNotFoundException([this.message]); @@ -30,7 +30,7 @@ class ApiException implements Exception { } } - final Response response; + final api.Response response; final dynamic message; } @@ -105,7 +105,7 @@ class AlbumItemPermissionException implements Exception { } class CollectionPartialShareException implements Exception { - const CollectionPartialShareException([this.message]); + const CollectionPartialShareException(this.shareeName, [this.message]); @override String toString() { @@ -116,5 +116,22 @@ class CollectionPartialShareException implements Exception { } } + final String shareeName; + final dynamic message; +} + +class CollectionPartialUnshareException implements Exception { + const CollectionPartialUnshareException(this.shareeName, [this.message]); + + @override + String toString() { + if (message == null) { + return "CollectionPartialUnshareException"; + } else { + return "CollectionPartialUnshareException: $message"; + } + } + + final String shareeName; final dynamic message; } diff --git a/app/lib/widget/share_collection_dialog.dart b/app/lib/widget/share_collection_dialog.dart index b566d1b2..b14f70f1 100644 --- a/app/lib/widget/share_collection_dialog.dart +++ b/app/lib/widget/share_collection_dialog.dart @@ -83,9 +83,19 @@ class _WrappedShareCollectionDialogState listener: (context, state) { if (state.error != null) { if (state.error!.error is CollectionPartialShareException) { - // TODO localize string - SnackBarManager().showSnackBar(const SnackBar( - content: Text("Collection shared partially"), + final e = state.error!.error as CollectionPartialShareException; + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global() + .shareAlbumSuccessWithErrorNotification(e.shareeName)), + duration: k.snackBarDurationNormal, + )); + } else if (state.error!.error + is CollectionPartialUnshareException) { + final e = + state.error!.error as CollectionPartialUnshareException; + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global() + .unshareAlbumSuccessWithErrorNotification(e.shareeName)), duration: k.snackBarDurationNormal, )); } else { diff --git a/app/lib/widget/share_collection_dialog/bloc.dart b/app/lib/widget/share_collection_dialog/bloc.dart index 47585d3f..74688aca 100644 --- a/app/lib/widget/share_collection_dialog/bloc.dart +++ b/app/lib/widget/share_collection_dialog/bloc.dart @@ -117,12 +117,15 @@ class _Bloc extends Bloc<_Event, _State> { ), ], )); - await collectionsController.share(state.collection, ev.sharee); - emit(state.copyWith( - processingShares: state.processingShares - .where((s) => s.userId != ev.sharee.shareWith) - .toList(), - )); + try { + await collectionsController.share(state.collection, ev.sharee); + } finally { + emit(state.copyWith( + processingShares: state.processingShares + .where((s) => s.userId != ev.sharee.shareWith) + .toList(), + )); + } } Future _onUnshare(_Unshare ev, Emitter<_State> emit) async { @@ -133,12 +136,15 @@ class _Bloc extends Bloc<_Event, _State> { ev.share, ], )); - await collectionsController.unshare(state.collection, ev.share.userId); - emit(state.copyWith( - processingShares: state.processingShares - .where((s) => s.userId != ev.share.userId) - .toList(), - )); + try { + await collectionsController.unshare(state.collection, ev.share); + } finally { + emit(state.copyWith( + processingShares: state.processingShares + .where((s) => s.userId != ev.share.userId) + .toList(), + )); + } } void _onSetError(_SetError ev, Emitter<_State> emit) { From ddc3d64b5bcf3f3335f0c456ab0d7290a2853a83 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 23:52:27 +0800 Subject: [PATCH 58/67] Regression: missing draggable list hint --- app/lib/widget/collection_browser.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index c0ed8a00..5599750b 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -39,6 +39,7 @@ 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/or_null.dart'; +import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/archive_file.dart'; import 'package:nc_photos/use_case/collection/import_pending_shared_collection.dart'; @@ -187,6 +188,26 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> } }, ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.isEditMode != current.isEditMode, + listener: (context, state) { + final c = KiwiContainer().resolve(); + final bloc = context.read<_Bloc>(); + final canSort = + CollectionAdapter.of(c, bloc.account, state.collection) + .isPermitted(CollectionCapability.manualSort); + if (canSort && + !SessionStorage().hasShowDragRearrangeNotification) { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(L10n.global().albumEditDragRearrangeNotification), + duration: k.snackBarDurationNormal, + )); + SessionStorage().hasShowDragRearrangeNotification = true; + } + }, + ), BlocListener<_Bloc, _State>( listenWhen: (previous, current) => previous.error != current.error, From 0212061dd57c3b53669cfd333abec220b5de4891 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 23:53:04 +0800 Subject: [PATCH 59/67] Regression: missing action button in partial share error snackbar --- app/lib/widget/share_collection_dialog.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/lib/widget/share_collection_dialog.dart b/app/lib/widget/share_collection_dialog.dart index b14f70f1..7f8b4453 100644 --- a/app/lib/widget/share_collection_dialog.dart +++ b/app/lib/widget/share_collection_dialog.dart @@ -13,6 +13,7 @@ import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/collections_controller.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/util.dart'; import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/exception.dart'; @@ -21,6 +22,7 @@ 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/suggester.dart'; +import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; import 'package:to_string/to_string.dart'; @@ -87,6 +89,10 @@ class _WrappedShareCollectionDialogState SnackBarManager().showSnackBar(SnackBar( content: Text(L10n.global() .shareAlbumSuccessWithErrorNotification(e.shareeName)), + action: SnackBarAction( + label: L10n.global().fixButtonLabel, + onPressed: _onFixPressed, + ), duration: k.snackBarDurationNormal, )); } else if (state.error!.error @@ -96,6 +102,10 @@ class _WrappedShareCollectionDialogState SnackBarManager().showSnackBar(SnackBar( content: Text(L10n.global() .unshareAlbumSuccessWithErrorNotification(e.shareeName)), + action: SnackBarAction( + label: L10n.global().fixButtonLabel, + onPressed: _onFixPressed, + ), duration: k.snackBarDurationNormal, )); } else { @@ -136,6 +146,16 @@ class _WrappedShareCollectionDialogState ); } + void _onFixPressed() { + final bloc = context.read<_Bloc>(); + final collection = bloc.state.collection; + final album = (collection.contentProvider as CollectionAlbumProvider).album; + Navigator.of(context).pushNamed( + AlbumShareOutlierBrowser.routeName, + arguments: AlbumShareOutlierBrowserArguments(bloc.account, album), + ); + } + late final _bloc = context.read<_Bloc>(); } From 9847ac28fc2f033d3318bd06044bced1b8a5413c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 23:53:18 +0800 Subject: [PATCH 60/67] Make string localizable --- app/lib/widget/settings.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index a2e8b3c0..bce7268d 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -224,13 +224,13 @@ class _SettingsState extends State { .valueOrNull, builder: (context, snapshot) { if (!snapshot.hasData) { - return const ListTile( - title: Text("Server"), + return ListTile( + title: Text(L10n.global().settingsServerVersionTitle), ); } else { final status = snapshot.requireData!; return ListTile( - title: const Text("Server"), + title: Text(L10n.global().settingsServerVersionTitle), subtitle: Text( "${status.productName} ${status.majorVersion} (${status.versionName})"), ); From 24d878b93eb910d35ac17797dee3edd2d5c6dd51 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 May 2023 23:55:13 +0800 Subject: [PATCH 61/67] Remove obsolete strings --- app/lib/l10n/app_cs.arb | 57 -------------------------- app/lib/l10n/app_de.arb | 21 ---------- app/lib/l10n/app_el.arb | 57 -------------------------- app/lib/l10n/app_en.arb | 56 ------------------------- app/lib/l10n/app_es.arb | 57 -------------------------- app/lib/l10n/app_fi.arb | 57 -------------------------- app/lib/l10n/app_fr.arb | 57 -------------------------- app/lib/l10n/app_pl.arb | 57 -------------------------- app/lib/l10n/app_pt.arb | 57 -------------------------- app/lib/l10n/app_ru.arb | 57 -------------------------- app/lib/l10n/app_zh.arb | 57 -------------------------- app/lib/l10n/app_zh_Hant.arb | 57 -------------------------- app/lib/l10n/untranslated-messages.txt | 4 -- 13 files changed, 651 deletions(-) diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index 2a30d0e8..be746cb3 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 položka odebrána z alba} =2{2 položky odebrány z alba} =3{3 položky odebrány z alba} =4{4 položky odebrány z alba} other{{count} položek odebráno z alba}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Nepodařilo se odebrat položky z alba", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -596,10 +587,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Upravit album", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Hotovo", "editTooltip": "Upravit", "editAccountConflictFailureNotification": "Již existuje účet se stejným nastavením", @@ -806,14 +793,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Rušení nastavení obalu", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Nastavení obalu úspěšně zrušeno", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Ztlumit", "@muteTooltip": { "description": "Mute the video player" @@ -975,37 +954,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Přidávání alba {album} do vaší sbírky", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "Album {album} úspěšně přidáno do vaší sbírky", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Sdílet s uživatelem", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Album sdíleno s uživatelem {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Album sdíleno s uživatelem {user}, nepodařilo se ale sdílet některé soubory", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -1015,15 +967,6 @@ } } }, - "unshareAlbumSuccessNotification": "Zrušeno sdílení alba s uživatelem {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Zrušeno sdílení alba s uživatelem {user}, nepodařilo se ale zrušit sdílení některých souborů", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index a71c91e2..a42d199b 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 Element vom Album entfernt} other{{count} Elemente vom Album entfernt}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Fehler beim Entfernen von Elementen aus dem Album", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -463,10 +454,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Album bearbeiten", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Fertig", "editTooltip": "Bearbeiten", "editAccountConflictFailureNotification": "Es existiert bereits ein Konto mit denselben Einstellungen", @@ -660,14 +647,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Cover aufheben", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Coever erfolgreich aufgehoben", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Stumm", "@muteTooltip": { "description": "Mute the video player" diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index c54bb4d4..87ab651a 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 αντικείμενο αφαιρέθηκε από τη συλλογή} other{{count} αντικείμενα αφαιρέθηκαν από τη συλλογή}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Αποτυχία αφαίρεσης αντικειμένων από τη συλλογή", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -534,10 +525,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Επεξεργασία συλλογής", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Έγινε", "editTooltip": "Edit", "editAccountConflictFailureNotification": "An account already exists with the same settings", @@ -679,14 +666,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Το εξώφυλλο αφαιρείται", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Επιτυχής αφαίρεση εξωφύλλου", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Σίγαση", "@muteTooltip": { "description": "Mute the video player" @@ -834,37 +813,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Προσθήκη του άλμπουμ {album} στη συλλογή", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "Το άλμπουμ {album} προστέθηκε στη συλλογή με επιτυχία", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Κοινοποίηση με χρήστη", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Το άλμπουμ κοινοποιήθηκε στο χρήστη {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Το άλμπουμ κοινοποιήθηκε στο χρήστη {user}, αλλά απέτυχε να μοιραστεί ορισμένα αρχεία", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -874,15 +826,6 @@ } } }, - "unshareAlbumSuccessNotification": "Καταργήθηκε η κοινή χρήση του άλμπουμ με το χρήστη {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Καταργήθηκε η κοινή χρήση του άλμπουμ με τον χρήστη {user}, αλλά απέτυχε η κατάργηση κοινής χρήσης ορισμένων αρχείων", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index a926e9fd..c133c367 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 item removed from album} other{{count} items removed from album}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "(TO BE REMOVED) Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Failed removing items from album", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -604,10 +595,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Edit album", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Done", "editTooltip": "Edit", "editAccountConflictFailureNotification": "An account already exists with the same settings", @@ -814,14 +801,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Unsetting cover", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Unset cover successfully", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Mute", "@muteTooltip": { "description": "Mute the video player" @@ -983,36 +962,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Adding {album} to your Collections", - "@addToCollectionsViewProcessingNotification": { - "description": "Making an album visible in a user's Collections view", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "Added {album} to your collection successfully", - "@addToCollectionsViewSuccessNotification": { - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Share with user", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Album shared with {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Album shared with {user}, but failed to share some files", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -1022,15 +975,6 @@ } } }, - "unshareAlbumSuccessNotification": "Album unshared with {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Album unshared with {user}, but failed to unshare some files", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 267a657a..0feb830c 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 elemento quitado del álbum} other{{count} elementos quitados del álbum}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Error al quitar elementos del álbum", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -596,10 +587,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Editar álbum", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Listo", "editTooltip": "Editar", "editAccountConflictFailureNotification": "Ya existe una cuenta con los mismos ajustes", @@ -806,14 +793,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Quitando portada", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Portada quitada", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Silenciar", "@muteTooltip": { "description": "Mute the video player" @@ -975,37 +954,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Añadiendo {album} a tu colección", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "Añadido {album} a tu colección", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Compartir con un usuario", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Álbum compartido con {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Álbum compartido con {user}, pero algunos archivos fallaron al comnpartirlos", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -1015,15 +967,6 @@ } } }, - "unshareAlbumSuccessNotification": "Álbum no compartido con {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Álbum no compartido con {user}, pero algunos archivos fallaron al dejar de compartirlos", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index 1abe4e3e..4c6ae8f2 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 kohde poistettu albumista} other{{count} kohdetta poistettu albumista}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Kohteiden poistaminen albumista epäonnistui", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -596,10 +587,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Muokkaa albumia", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Valmis", "editTooltip": "Edit", "editAccountConflictFailureNotification": "Käyttäjätunnus samoilla asetuksilla on jo olemassa", @@ -806,14 +793,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Poistetaan albumin kantta", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Albumin kannen poisto onnistui", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Mykistä", "@muteTooltip": { "description": "Mute the video player" @@ -975,37 +954,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Lisätään {album} kokoelmaasi", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "{album} lisätty kokoelmaasi", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Jaa toisen käyttäjän kanssa", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Albumi jaettu käyttäjän {user} kanssa", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Albumi jaettu käyttäjän {user} kanssa. Joidenkin tiedostojen jako kuitenkin epäonnistui", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -1015,15 +967,6 @@ } } }, - "unshareAlbumSuccessNotification": "Albumin jakaminen käyttäjän kanssa {user} lopetettu", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Albumin jakaminen käyttäjän {user} kanssa lopetettu. Joidenkin tiedostojen jakaminen kuitenkin epäonnistui", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index 7000337a..557035c6 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -119,15 +119,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 élément supprimé de l'album} other{{count} éléments supprimés de l'album}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Échec de la suppression des éléments de l'album", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -504,10 +495,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Modifier l'album", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Terminé", "editTooltip": "Modifier", "editAccountConflictFailureNotification": "Un compte existe déjà avec les mêmes paramètres", @@ -700,14 +687,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Suppression de la couverture", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Couverture enlevé avec succès", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Mettre en sourdine", "@muteTooltip": { "description": "Mute the video player" @@ -855,37 +834,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Ajout de {album} à votre collection", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "{album} a été ajouté avec succès à votre collection", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Partager avec un utilisateur", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Album partagé avec {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Album partagé avec {user}, mais échec du partage de certains fichiers", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -895,15 +847,6 @@ } } }, - "unshareAlbumSuccessNotification": "Suppression de partage de l'album avec {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Suppression de partage de l'album avec {user}, mais échec du partage de certains fichiers", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index d66ef9a7..275f97fc 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{Jeden element usunięty z albumu} other{Z albumu usunięto elementy w ilości: {count}}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Nie udało się usunąć elementów z albumu", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -508,10 +499,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Edytuj album", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Gotowe", "editTooltip": "Edytuj", "editAccountConflictFailureNotification": "Konto z tymi samymi ustawieniami już istnieje", @@ -710,14 +697,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Odznaczanie okładki", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Odznaczanie okładki pomyślne", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Wycisz", "@muteTooltip": { "description": "Mute the video player" @@ -865,37 +844,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Dodawanie {album} do kolekcji", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "Pomyślnie dodano album {album} do kolekcji", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Udostępnij użytkownikowi", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Album współdzielony z {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Album współdzielony z {user}, jednak nie udało się udostępnić niektórych plików", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -905,15 +857,6 @@ } } }, - "unshareAlbumSuccessNotification": "Zakończono współdzielenie albumu z {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Zakończono współdzielenie albumu z {user}, jednak nie udało się zakończyć udostępniania niektórych plików", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index 50f3d427..5bcef3be 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 item foi removido do álbum} other{{count} itens foram removidos do álbum}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Falha ao remover os itens selecionados do álbum", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -596,10 +587,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Editar álbum", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Pronto", "editTooltip": "Editar", "editAccountConflictFailureNotification": "Uma conta já existe com essas mesmas configurações", @@ -806,14 +793,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Desmarcando como capa", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Desmarcado como capa com sucesso", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Sem áudio", "@muteTooltip": { "description": "Mute the video player" @@ -975,37 +954,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Adicionando {album} para minha coleção", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "{album} adicionado à coleção com sucesso", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Compartilhar com usuário", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Álbum compartilhado com {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Álbum foi compartilhado com {user}, porém falhou em prover alguns arquivos", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -1015,15 +967,6 @@ } } }, - "unshareAlbumSuccessNotification": "Compartilhamento de álbum removido para {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "Compartilhamento de álbum removido para {user}, porém falhou em remover o compartilhamento de alguns arquivos", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index ce788df0..2e163c67 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 фото убрано из альбома} other{{count} фото убрано из альбома}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "Не удалось убрать фото из альбома", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -453,10 +444,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "Редактировать альбом", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "Завершить", "editTooltip": "Редактирование", "editAccountConflictFailureNotification": "Запись с такими настройками уже существует", @@ -643,14 +630,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "Сброс обложки альбома", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "Обложка альбома сброшена", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "Убрать звук", "unmuteTooltip": "Включить звук", "errorUnauthenticated": "Неавторизованный доступ. Если ошибка возникает снова, попробуйте перелогиниться", @@ -883,37 +862,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "Добавление {album} в коллекцию", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "{album} успешно добавлен в коллекцию", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "Предоставить общий доступ пользователю", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "Ползователю {user} предоставлен общий доступ к альбому", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "Ползователю {user} предоставлен общий доступ к альбому, но не удалось предоставить общий доступ к некоторым файлам", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -923,15 +875,6 @@ } } }, - "unshareAlbumSuccessNotification": "У ползователя {user} отозван общий доступ к альбому", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "У ползователя {user} отозван общий доступ к альбому, но не удалось отозвать общий доступ к некоторым файлам", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 1c57b409..9a48732a 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, other{成功从相册中移除 {count} 个项目}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "未能从相册中移除项目", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -508,10 +499,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "修改相册", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "完成", "editTooltip": "修改", "editAccountConflictFailureNotification": "已存在相同设置的帐号", @@ -710,14 +697,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "正在取消设为相册封面", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "成功取消设为相册封面", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "静音", "@muteTooltip": { "description": "Mute the video player" @@ -865,37 +844,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "正在添加 {album} 到收藏库", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "成功添加 {album} 到收藏库", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "分享给用户", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "成功分享相册给 {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "成功分享相册给 {user},但部分照片未能分享", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -905,15 +857,6 @@ } } }, - "unshareAlbumSuccessNotification": "成功取消分享相册给 {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "成功取消分享相册给 {user},但部分照片未能取消分享", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/app_zh_Hant.arb b/app/lib/l10n/app_zh_Hant.arb index a4923bb3..929af946 100644 --- a/app/lib/l10n/app_zh_Hant.arb +++ b/app/lib/l10n/app_zh_Hant.arb @@ -123,15 +123,6 @@ "@deleteFailureNotification": { "description": "Inform user that the item cannot be deleted" }, - "removeSelectedFromAlbumSuccessNotification": "{count, plural, other{成功從相簿中移除 {count} 個項目}}", - "@removeSelectedFromAlbumSuccessNotification": { - "description": "Inform user that the selected items are removed from an album successfully", - "placeholders": { - "count": { - "example": "1" - } - } - }, "removeSelectedFromAlbumFailureNotification": "未能從相簿中移除項目", "@removeSelectedFromAlbumFailureNotification": { "description": "Inform user that the selected items cannot be removed from an album" @@ -508,10 +499,6 @@ "@albumImporterProgressText": { "description": "Message shown while importing" }, - "editAlbumMenuLabel": "修改相簿", - "@editAlbumMenuLabel": { - "description": "Edit the opened album" - }, "doneButtonTooltip": "完成", "editTooltip": "修改", "editAccountConflictFailureNotification": "已存在相同設置的帳戶", @@ -710,14 +697,6 @@ "@unsetAlbumCoverTooltip": { "description": "Unset the cover of the opened album" }, - "unsetAlbumCoverProcessingNotification": "正在取消設為相簿封面", - "@unsetAlbumCoverProcessingNotification": { - "description": "Unsetting the cover of the opened album" - }, - "unsetAlbumCoverSuccessNotification": "成功取消設為相簿封面", - "@unsetAlbumCoverSuccessNotification": { - "description": "Unset the cover of the opened album successfully" - }, "muteTooltip": "靜音", "@muteTooltip": { "description": "Mute the video player" @@ -865,37 +844,10 @@ "@addToCollectionsViewTooltip": { "description": "Albums shared with you are not automatically added to the Collections view, unless you choose to do so, which is what this button does" }, - "addToCollectionsViewProcessingNotification": "正在新增 {album} 至收藏庫", - "@addToCollectionsViewProcessingNotification": { - "description": "Adding an album to your collection", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, - "addToCollectionsViewSuccessNotification": "成功新增 {album} 至收藏庫", - "@addToCollectionsViewSuccessNotification": { - "description": "Inform user that the selected items are deleted successfully", - "placeholders": { - "album": { - "example": "Sunday Walk" - } - } - }, "shareAlbumDialogTitle": "分享給用戶", "@shareAlbumDialogTitle": { "description": "Dialog to share an album with another user" }, - "shareAlbumSuccessNotification": "成功分享相簿給 {user}", - "@shareAlbumSuccessNotification": { - "description": "Shared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "shareAlbumSuccessWithErrorNotification": "成功分享相簿給 {user},但部分相片未能分享", "@shareAlbumSuccessWithErrorNotification": { "description": "Shared an album with another user successfully, but some files inside the album cannot be shared", @@ -905,15 +857,6 @@ } } }, - "unshareAlbumSuccessNotification": "成功取消分享相簿給 {user}", - "@unshareAlbumSuccessNotification": { - "description": "Unshared an album with another user successfully", - "placeholders": { - "user": { - "example": "Alice" - } - } - }, "unshareAlbumSuccessWithErrorNotification": "成功取消分享相簿給 {user},但部分相片未能取消分享", "@unshareAlbumSuccessWithErrorNotification": { "description": "Unshared an album with another user successfully, but some files inside the album cannot be unshared", diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 3f503d26..70dd06dc 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -100,12 +100,8 @@ "unshareLinkShareDirDialogTitle", "unshareLinkShareDirDialogContent", "addToCollectionsViewTooltip", - "addToCollectionsViewProcessingNotification", - "addToCollectionsViewSuccessNotification", "shareAlbumDialogTitle", - "shareAlbumSuccessNotification", "shareAlbumSuccessWithErrorNotification", - "unshareAlbumSuccessNotification", "unshareAlbumSuccessWithErrorNotification", "fixSharesTooltip", "fixTooltip", From 39f356e0a115c61298130c5c66e6e166403e5d79 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 May 2023 03:01:04 +0800 Subject: [PATCH 62/67] Fix shared collection not shown in HomeCollections after importing --- .../controller/collections_controller.dart | 20 +++++++++++++++++++ app/lib/widget/collection_browser/bloc.dart | 9 +++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 95f9647b..975c064a 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -19,6 +19,7 @@ import 'package:nc_photos/or_null.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/import_pending_shared_collection.dart'; import 'package:nc_photos/use_case/collection/list_collection.dart'; import 'package:nc_photos/use_case/collection/remove_collections.dart'; import 'package:nc_photos/use_case/collection/share_collection.dart'; @@ -261,6 +262,25 @@ class CollectionsController { } } + /// See [ImportPendingSharedCollection] + Future importPendingSharedCollection( + Collection collection) async { + try { + final newCollection = + await ImportPendingSharedCollection(_c)(account, collection); + _dataStreamController.addWithValue((v) => v.copyWith( + data: _prepareDataFor([ + newCollection, + ...v.data.map((e) => e.collection), + ]), + )); + return newCollection; + } catch (e, stackTrace) { + _dataStreamController.addError(e, stackTrace); + return null; + } + } + Future _load() async { var lastData = const CollectionStreamEvent( data: [], diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index 534a9420..b6cfd1c8 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -140,10 +140,11 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { Future _onImportPendingSharedCollection( _ImportPendingSharedCollection ev, Emitter<_State> emit) async { _log.info(ev); - // pending collections are always ad hoc - final newCollection = - await ImportPendingSharedCollection(_c)(account, state.collection); - emit(state.copyWith(importResult: newCollection)); + final newCollection = await collectionsController + .importPendingSharedCollection(state.collection); + if (newCollection != null) { + emit(state.copyWith(importResult: newCollection)); + } } void _onBeginEdit(_BeginEdit ev, Emitter<_State> emit) { From 6d25f474123cc8c1c63695ba68a5d0f374d5ada1 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 May 2023 16:20:02 +0800 Subject: [PATCH 63/67] Fix dialog not popped correctly --- app/lib/widget/new_collection_dialog.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/lib/widget/new_collection_dialog.dart b/app/lib/widget/new_collection_dialog.dart index a1e2ea91..8269a256 100644 --- a/app/lib/widget/new_collection_dialog.dart +++ b/app/lib/widget/new_collection_dialog.dart @@ -192,7 +192,9 @@ class _WrappedNewCollectionDialogState content: Text(exception_util.toUserString(e)), duration: k.snackBarDurationNormal, )); - Navigator.of(context).pop(); + Navigator.of(context) + ..pop() + ..pop(); } } From 15687879baa6ace9661af5720b070f46daf402de Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 May 2023 19:23:10 +0800 Subject: [PATCH 64/67] Use toast instead of snackbar in dialog to prevent it showing underneath --- app/lib/l10n/app_en.arb | 4 -- app/lib/toast.dart | 17 +++++++ app/lib/widget/export_collection_dialog.dart | 9 ++-- app/lib/widget/file_sharer.dart | 24 +++++----- app/lib/widget/new_collection_dialog.dart | 14 +++--- app/lib/widget/share_collection_dialog.dart | 48 ++++++-------------- app/pubspec.lock | 7 +++ app/pubspec.yaml | 1 + 8 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 app/lib/toast.dart diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index c133c367..12ec304c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -992,10 +992,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "FIX", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Fix all", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/toast.dart b/app/lib/toast.dart new file mode 100644 index 00000000..072ac113 --- /dev/null +++ b/app/lib/toast.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +class AppToast { + static Future showToast( + BuildContext context, { + required String msg, + required Duration duration, + }) { + return Fluttertoast.showToast( + msg: msg, + timeInSecForIosWeb: duration.inSeconds, + backgroundColor: Theme.of(context).snackBarTheme.backgroundColor, + textColor: Theme.of(context).snackBarTheme.contentTextStyle!.color, + ); + } +} diff --git a/app/lib/widget/export_collection_dialog.dart b/app/lib/widget/export_collection_dialog.dart index 585414f4..c5488f02 100644 --- a/app/lib/widget/export_collection_dialog.dart +++ b/app/lib/widget/export_collection_dialog.dart @@ -14,7 +14,7 @@ import 'package:nc_photos/entity/collection_item.dart'; 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/snack_bar_manager.dart'; +import 'package:nc_photos/toast.dart'; import 'package:nc_photos/widget/processing_dialog.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:to_string/to_string.dart'; @@ -73,10 +73,11 @@ class _WrappedExportCollectionDialogState listenWhen: (previous, current) => previous.error != current.error, listener: (_, state) { if (state.error != null) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.error!.error)), + AppToast.showToast( + context, + msg: exception_util.toUserString(state.error!.error), duration: k.snackBarDurationNormal, - )); + ); } }, ), diff --git a/app/lib/widget/file_sharer.dart b/app/lib/widget/file_sharer.dart index 37c1ae68..dd5e495d 100644 --- a/app/lib/widget/file_sharer.dart +++ b/app/lib/widget/file_sharer.dart @@ -22,7 +22,7 @@ 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/toast.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'; @@ -83,16 +83,17 @@ class _WrappedFileSharer extends StatelessWidget { listener: (context, state) { if (state.error != null) { if (state.error!.error is PermissionException) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().errorNoStoragePermission), + AppToast.showToast( + context, + msg: L10n.global().errorNoStoragePermission, duration: k.snackBarDurationNormal, - )); + ); } else { - SnackBarManager().showSnackBar(SnackBar( - content: - Text(exception_util.toUserString(state.error!.error)), + AppToast.showToast( + context, + msg: exception_util.toUserString(state.error!.error), duration: k.snackBarDurationNormal, - )); + ); } } }, @@ -102,10 +103,11 @@ class _WrappedFileSharer extends StatelessWidget { previous.message != current.message, listener: (context, state) { if (state.message != null) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(state.message!), + AppToast.showToast( + context, + msg: state.message!, duration: k.snackBarDurationNormal, - )); + ); } }, ), diff --git a/app/lib/widget/new_collection_dialog.dart b/app/lib/widget/new_collection_dialog.dart index 8269a256..b2bc6c80 100644 --- a/app/lib/widget/new_collection_dialog.dart +++ b/app/lib/widget/new_collection_dialog.dart @@ -23,7 +23,7 @@ import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/toast.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'; @@ -93,10 +93,11 @@ class _WrappedNewCollectionDialogState 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)), + AppToast.showToast( + context, + msg: exception_util.toUserString(state.error!.error), duration: k.snackBarDurationNormal, - )); + ); } }, ), @@ -188,8 +189,9 @@ class _WrappedNewCollectionDialogState ..pop(collection); } catch (e, stackTrace) { _log.shout("[_onResult] Failed", e, stackTrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(e)), + unawaited(AppToast.showToast( + context, + msg: exception_util.toUserString(e), duration: k.snackBarDurationNormal, )); Navigator.of(context) diff --git a/app/lib/widget/share_collection_dialog.dart b/app/lib/widget/share_collection_dialog.dart index 7f8b4453..c2885036 100644 --- a/app/lib/widget/share_collection_dialog.dart +++ b/app/lib/widget/share_collection_dialog.dart @@ -13,16 +13,14 @@ import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/controller/collections_controller.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/util.dart'; import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/exception.dart'; 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/snack_bar_manager.dart'; import 'package:nc_photos/suggester.dart'; -import 'package:nc_photos/widget/album_share_outlier_browser.dart'; +import 'package:nc_photos/toast.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; import 'package:to_string/to_string.dart'; @@ -86,34 +84,28 @@ class _WrappedShareCollectionDialogState if (state.error != null) { if (state.error!.error is CollectionPartialShareException) { final e = state.error!.error as CollectionPartialShareException; - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global() - .shareAlbumSuccessWithErrorNotification(e.shareeName)), - action: SnackBarAction( - label: L10n.global().fixButtonLabel, - onPressed: _onFixPressed, - ), + AppToast.showToast( + context, + msg: L10n.global() + .shareAlbumSuccessWithErrorNotification(e.shareeName), duration: k.snackBarDurationNormal, - )); + ); } else if (state.error!.error is CollectionPartialUnshareException) { final e = state.error!.error as CollectionPartialUnshareException; - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global() - .unshareAlbumSuccessWithErrorNotification(e.shareeName)), - action: SnackBarAction( - label: L10n.global().fixButtonLabel, - onPressed: _onFixPressed, - ), + AppToast.showToast( + context, + msg: L10n.global() + .unshareAlbumSuccessWithErrorNotification(e.shareeName), duration: k.snackBarDurationNormal, - )); + ); } else { - SnackBarManager().showSnackBar(SnackBar( - content: - Text(exception_util.toUserString(state.error!.error)), + AppToast.showToast( + context, + msg: exception_util.toUserString(state.error!.error), duration: k.snackBarDurationNormal, - )); + ); } } }, @@ -146,16 +138,6 @@ class _WrappedShareCollectionDialogState ); } - void _onFixPressed() { - final bloc = context.read<_Bloc>(); - final collection = bloc.state.collection; - final album = (collection.contentProvider as CollectionAlbumProvider).album; - Navigator.of(context).pushNamed( - AlbumShareOutlierBrowser.routeName, - arguments: AlbumShareOutlierBrowserArguments(bloc.account, album), - ); - } - late final _bloc = context.read<_Bloc>(); } diff --git a/app/pubspec.lock b/app/pubspec.lock index f63a2ef3..672dd818 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -616,6 +616,13 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + url: "https://pub.dartlang.org" + source: hosted + version: "8.2.1" frontend_server_client: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d67c0b90..4e4a6991 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: git: url: https://gitlab.com/nc-photos/exifdart.git ref: 1.3.0 + fluttertoast: ^8.2.1 flutter_background_service: git: url: https://gitlab.com/nc-photos/flutter_background_service.git From c6a48d2c022f2ba3bab39ff04468d5d10a5aad75 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 May 2023 19:23:18 +0800 Subject: [PATCH 65/67] Remove unused string --- app/lib/l10n/app_cs.arb | 4 ---- app/lib/l10n/app_el.arb | 4 ---- app/lib/l10n/app_es.arb | 4 ---- app/lib/l10n/app_fi.arb | 4 ---- app/lib/l10n/app_fr.arb | 4 ---- app/lib/l10n/app_pl.arb | 4 ---- app/lib/l10n/app_pt.arb | 4 ---- app/lib/l10n/app_ru.arb | 4 ---- app/lib/l10n/app_zh.arb | 4 ---- app/lib/l10n/app_zh_Hant.arb | 4 ---- 10 files changed, 40 deletions(-) diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index be746cb3..c930035c 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -984,10 +984,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "OPRAVIT", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Opravit vše", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index 87ab651a..6d900a2e 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -843,10 +843,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "ΕΠΙΔΙΟΡΘΩΣΗ", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Επιδιόρθωση όλων", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 0feb830c..c8a481c2 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -984,10 +984,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "REPARAR", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Reparar todo", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index 4c6ae8f2..d8326b4b 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -984,10 +984,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "KORJAA", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Korjaa kaikki", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index 557035c6..c672b2ca 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -864,10 +864,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "RÉPARER", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Tout réparer", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index 275f97fc..c5351336 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -874,10 +874,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "NAPRAW", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Napraw wszystko", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index 5bcef3be..c738da5e 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -984,10 +984,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "CONSERTAR", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Consertar tudo", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index 2e163c67..57d04624 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -892,10 +892,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "ИСПРАВИТЬ", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "Исправить всё", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 9a48732a..9aa6cd2b 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -874,10 +874,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "修复", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "修复全部", "@fixAllTooltip": { "description": "Fix all listed issues" diff --git a/app/lib/l10n/app_zh_Hant.arb b/app/lib/l10n/app_zh_Hant.arb index 929af946..e67573b2 100644 --- a/app/lib/l10n/app_zh_Hant.arb +++ b/app/lib/l10n/app_zh_Hant.arb @@ -874,10 +874,6 @@ "@fixTooltip": { "description": "Fix an issue" }, - "fixButtonLabel": "修正", - "@fixButtonLabel": { - "description": "Fix an issue" - }, "fixAllTooltip": "修正全部", "@fixAllTooltip": { "description": "Fix all listed issues" From d984f97e1e63b3e4d3d2a11b0fe8c6b1c0f00174 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 May 2023 19:49:56 +0800 Subject: [PATCH 66/67] Rename dialog properly as dialog --- app/lib/widget/collection_browser.dart | 3 +-- app/lib/widget/collection_browser/app_bar.dart | 2 +- ...ile_sharer.dart => file_sharer_dialog.dart} | 18 +++++++++--------- ...sharer.g.dart => file_sharer_dialog.g.dart} | 4 ++-- .../bloc.dart | 2 +- .../state_event.dart | 2 +- .../type.dart | 2 +- 7 files changed, 16 insertions(+), 17 deletions(-) rename app/lib/widget/{file_sharer.dart => file_sharer_dialog.dart} (96%) rename app/lib/widget/{file_sharer.g.dart => file_sharer_dialog.g.dart} (98%) rename app/lib/widget/{file_sharer => file_sharer_dialog}/bloc.dart (99%) rename app/lib/widget/{file_sharer => file_sharer_dialog}/state_event.dart (98%) rename app/lib/widget/{file_sharer => file_sharer_dialog}/type.dart (65%) diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 5599750b..1b26d3a5 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -42,7 +42,6 @@ import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/archive_file.dart'; -import 'package:nc_photos/use_case/collection/import_pending_shared_collection.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/widget/album_share_outlier_browser.dart'; @@ -51,7 +50,7 @@ import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/draggable_item_list.dart'; import 'package:nc_photos/widget/export_collection_dialog.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; -import 'package:nc_photos/widget/file_sharer.dart'; +import 'package:nc_photos/widget/file_sharer_dialog.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'; diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index bba5d0fc..d3f29098 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -295,7 +295,7 @@ class _SelectionAppBar extends StatelessWidget { } final result = await showDialog( context: context, - builder: (context) => FileSharer( + builder: (context) => FileSharerDialog( account: bloc.account, files: selected, ), diff --git a/app/lib/widget/file_sharer.dart b/app/lib/widget/file_sharer_dialog.dart similarity index 96% rename from app/lib/widget/file_sharer.dart rename to app/lib/widget/file_sharer_dialog.dart index dd5e495d..26402d53 100644 --- a/app/lib/widget/file_sharer.dart +++ b/app/lib/widget/file_sharer_dialog.dart @@ -37,10 +37,10 @@ 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'; +part 'file_sharer_dialog.g.dart'; +part 'file_sharer_dialog/bloc.dart'; +part 'file_sharer_dialog/state_event.dart'; +part 'file_sharer_dialog/type.dart'; typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; @@ -48,8 +48,8 @@ typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; /// /// 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({ +class FileSharerDialog extends StatelessWidget { + const FileSharerDialog({ super.key, required this.account, required this.files, @@ -63,7 +63,7 @@ class FileSharer extends StatelessWidget { account: account, files: files, ), - child: const _WrappedFileSharer(), + child: const _WrappedFileSharerDialog(), ); } @@ -71,8 +71,8 @@ class FileSharer extends StatelessWidget { final List files; } -class _WrappedFileSharer extends StatelessWidget { - const _WrappedFileSharer(); +class _WrappedFileSharerDialog extends StatelessWidget { + const _WrappedFileSharerDialog(); @override Widget build(BuildContext context) { diff --git a/app/lib/widget/file_sharer.g.dart b/app/lib/widget/file_sharer_dialog.g.dart similarity index 98% rename from app/lib/widget/file_sharer.g.dart rename to app/lib/widget/file_sharer_dialog.g.dart index fcba4050..b7b7ee94 100644 --- a/app/lib/widget/file_sharer.g.dart +++ b/app/lib/widget/file_sharer_dialog.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'file_sharer.dart'; +part of 'file_sharer_dialog.dart'; // ************************************************************************** // CopyWithLintRuleGenerator @@ -141,7 +141,7 @@ extension _$_BlocNpLog on _Bloc { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.file_sharer._Bloc"); + static final log = Logger("widget.file_sharer_dialog._Bloc"); } // ************************************************************************** diff --git a/app/lib/widget/file_sharer/bloc.dart b/app/lib/widget/file_sharer_dialog/bloc.dart similarity index 99% rename from app/lib/widget/file_sharer/bloc.dart rename to app/lib/widget/file_sharer_dialog/bloc.dart index 51f0194c..2fb07d24 100644 --- a/app/lib/widget/file_sharer/bloc.dart +++ b/app/lib/widget/file_sharer_dialog/bloc.dart @@ -1,4 +1,4 @@ -part of '../file_sharer.dart'; +part of '../file_sharer_dialog.dart'; @npLog class _Bloc extends Bloc<_Event, _State> implements BlocTag { diff --git a/app/lib/widget/file_sharer/state_event.dart b/app/lib/widget/file_sharer_dialog/state_event.dart similarity index 98% rename from app/lib/widget/file_sharer/state_event.dart rename to app/lib/widget/file_sharer_dialog/state_event.dart index ef2e399d..c7c8258a 100644 --- a/app/lib/widget/file_sharer/state_event.dart +++ b/app/lib/widget/file_sharer_dialog/state_event.dart @@ -1,4 +1,4 @@ -part of '../file_sharer.dart'; +part of '../file_sharer_dialog.dart'; @genCopyWith @toString diff --git a/app/lib/widget/file_sharer/type.dart b/app/lib/widget/file_sharer_dialog/type.dart similarity index 65% rename from app/lib/widget/file_sharer/type.dart rename to app/lib/widget/file_sharer_dialog/type.dart index f0d14d44..88229b07 100644 --- a/app/lib/widget/file_sharer/type.dart +++ b/app/lib/widget/file_sharer_dialog/type.dart @@ -1,4 +1,4 @@ -part of '../file_sharer.dart'; +part of '../file_sharer_dialog.dart'; enum ShareMethod { file, From b9c1a2b0bb4b3cba7c06d122441b8cfb62896695 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 May 2023 21:56:10 +0800 Subject: [PATCH 67/67] Update untranslated messages --- app/lib/l10n/untranslated-messages.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 70dd06dc..12e52afc 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -105,7 +105,6 @@ "unshareAlbumSuccessWithErrorNotification", "fixSharesTooltip", "fixTooltip", - "fixButtonLabel", "fixAllTooltip", "missingShareDescription", "extraShareDescription", @@ -339,6 +338,7 @@ ], "fi": [ + "nameInputInvalidEmpty", "settingsServerVersionTitle", "createCollectionFailureNotification", "addItemToCollectionTooltip",