Compare commits

..

37 Commits

Author SHA1 Message Date
Alex The Bot
d5596cf6a2 Version v1.52.0 2023-03-28 20:33:08 +00:00
Jason Rasmussen
b0d5c7035b feat(server): apply storage migration after exif completes (#2093)
* feat(server): apply storage migraiton after exif completes

* feat: same for videos

* fix: migration for live photos
2023-03-28 15:04:11 -05:00
Jason Rasmussen
3497a0de54 chore: cleanup template variables (#2107) 2023-03-28 14:27:36 -05:00
Alex
117f2fa00d chore(ci): update prepare release action to bypass branch check (#2106)
* chore: fix api

* chore(ci): update prepare release action to bypass branch check
2023-03-28 14:26:55 -05:00
Sergey Kondrikov
2c67090e3c feat(server): add transcode presets (#2084)
* feat: add transcode presets

* Add migration

* chore: generate api

* refactor: use enum type instead of string for transcode option

* chore: generate api

* refactor: enhance readability of runVideoEncode method

* refactor: reuse SettingSelect for transcoding presets

* refactor: simplify return statement

* chore: regenerate api

* fix: correct label attribute

* Update import

* fix test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-03-28 14:03:43 -05:00
Parikshit Misra
b49f66bbc9 feat(mobile): uploading files in chunk (#2101) 2023-03-28 13:41:55 -05:00
Jason Rasmussen
9adbbd42be feat(server): resume queues (#2104)
* feat(server): resume queues

* chore: regenerate open-api
2023-03-28 13:25:22 -05:00
Jason Rasmussen
8563bd463c fix(cli): clean up set intervals (#2103) 2023-03-28 13:24:14 -05:00
Jason Rasmussen
da5a6d2272 fix(cli): missing dep in immich cli (#2094)
* fix: missing dep in immich cli

* fix: imports
2023-03-28 11:29:20 -05:00
Alex
0854737be2 feat(mobile): improve explore page and allow metadata search (#2097) 2023-03-28 15:34:06 +00:00
Michel Heusschen
f0e272d0f2 feat(server): change clipembedding entity type (#2091) 2023-03-28 09:53:35 -05:00
Jason Rasmussen
e0b80f49b6 fix(server): increase typesense start-up settings (#2095) 2023-03-27 14:00:32 -05:00
Michel Heusschen
089dbdbd7e feat(server): require auth for more endpoints (#2092)
* feat(server): require auth for more endpoints

* dev: add authorization header to profile image on mobile

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-03-27 09:38:54 -05:00
Michel Heusschen
4e526dfaae feat(web): improve and refactor thumbnails (#2087)
* feat(web): improve and refactor thumbnails

* only play live photos on icon hover
2023-03-26 22:53:35 -05:00
Fynn Petersen-Frey
cae37657e9 feature(mobile): Hardening synchronization mechanism + Pull to refresh (#2085)
* fix(mobile): allow syncing duplicate local IDs

* enable to run isar unit tests on CI

* serialize sync operations, add pull to refresh on timeline

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
2023-03-26 21:35:52 -05:00
Zeyad Tamimi
1a94530935 [ Mobile ] Fixed mobile app not reporting webm MIME type (#2090) 2023-03-27 00:38:23 +00:00
Alex
cd59f7aad6 fix(server) get all query does not respect asset type (#2089)
* chore: fix api

* fix(server) get all query does not respect asset type
2023-03-26 10:41:55 -05:00
Michel Heusschen
c74fba483d feat(server): improve and refactor get all albums (#2048)
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-25 21:46:48 -05:00
Jason Rasmussen
2400004f41 feat(server): split generated content into a separate folder (#2047)
* feat: organize media folders

* fix: tests
2023-03-25 09:50:57 -05:00
Alex
b862c20e8e chore: fix api (#2079) 2023-03-25 04:22:02 +00:00
martyfuhry
501b96baf7 feat(mobile): Explore favorites, recently added, videos, and motion photos (#2076)
* Added placeholder for search explore

* refactor immich asset grid to use ref and provider

* all videos page

* got favorites, recently added, videos, and motion videos all using the immich grid

* Fixed issue with hero animations

* theming

* localization

* delete empty file

* style text

* Styling icons

* more styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-24 22:44:53 -05:00
Jonathan Jogenfors
d2600e0ddd Documentation: Add FAQ items for reverse proxy and rpi performance (#2070)
* Add FAQ items for reverse proxy and rpi performance

* Fix wording
2023-03-24 09:36:44 -05:00
Michel Heusschen
7d799b785e fix(web): remove protocol header (#2068) 2023-03-24 07:20:06 -05:00
Jason Rasmussen
e36b620020 refactor(server): cron jobs (#2067) 2023-03-24 07:19:48 -05:00
Jason Rasmussen
1efc74dabc refactor(server): common (#2066) 2023-03-23 23:55:15 -05:00
Jason Rasmussen
54f98053a8 chore(server): cleanup controllers (#2065) 2023-03-23 23:53:56 -05:00
Jason Rasmussen
6745826f35 refactor(server): media service (#2051)
* refactor(server): media service

* merge main

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-23 21:40:46 -05:00
Jason Rasmussen
bbd897b8ff refactor(server): asset serve files (#2052) 2023-03-23 21:40:30 -05:00
Alex Tran
586590e9ec fix(web): unused variables 2023-03-23 21:13:28 -05:00
Alex
4bf50a0b46 feat(web): better search bar (#2062) 2023-03-23 17:57:49 -05:00
Fynn Petersen-Frey
40832f0ea7 refactor(mobile): store backup settings on device (#2054)
Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-23 10:25:58 -05:00
martyfuhry
32a065afc7 feat(mobile): Use new search API and GridView for Places / Locations (#2043)
* Use new search API and GridView for Places / Locations

* Fixes search service by adding clip: true

* Rebased from master, uses view all explore grid now

* localized view all button

* adds empty

* style text

* Fix issue with horizontal Things not render due to missing height info

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-03-23 10:08:14 -05:00
Michel Heusschen
4dafc74223 fix(server): invalid video duration format (#2058) 2023-03-23 09:27:29 -05:00
bo0tzz
8adf1231a3 fix(server): Do not change file ext case on upload (#2056) 2023-03-23 09:06:40 -05:00
Michel Heusschen
c00624f209 chore: bump openapi version to v1.51.2 (#2059) 2023-03-23 06:42:33 -05:00
Fynn Petersen-Frey
eccde8fa07 refactor(mobile): migrate all Hive boxes to Isar database (#2036) 2023-03-22 20:36:44 -05:00
Skyler Mäntysaari
0616a66b05 feat(server): Allow .mkv, .wmv, .flv, .mpg videos to be uploaded. (#2045) 2023-03-22 18:34:13 -05:00
218 changed files with 6189 additions and 3066 deletions

View File

@@ -41,8 +41,9 @@ jobs:
id: push-tag
uses: EndBug/add-and-commit@v9
with:
author_name: Immich Release Bot
author_email: bot@immich.app
author_name: Alex The Bot
author_email: alex.tran1502@gmail.com
default_author: user_info
message: "Version ${{ env.IMMICH_VERSION }}"
tag: ${{ env.IMMICH_VERSION }}
push: true

View File

@@ -14,7 +14,11 @@ sidebar_position: 7
### How can I sync an existing directory with Immich's server?
Immich doesn't have the mechanism to sync an existing directory with the server. There is however, a helper CLI tool to help you bulk upload the existing photos and videos to the server. You can find the guide to use the CLI tool [here](/docs/features/bulk-upload.md).
Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich.
### Why doesn't Immich watch an existing photo gallery directory?
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### Why does my uploaded photo show up with the wrong date or time in Immich?
@@ -29,14 +33,19 @@ As an example, the following modification of ```docker-compose.yml``` will set t
- TZ=Europe/Stockholm # <---- Add this line in the microservices config
```
### Why doesn't Immich watch an existing photo gallery directory?
### Why are only photos and not videos being uploaded to Immich?
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes.
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### Why is Immich slow on low-memory systems like the Raspberry Pi?
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
### In the uploads folder, why are photos stored in the wrong date?
This is fixed by running the storage migration job.
### Why is object detection not very good?
The model we used for machine learning is a prebuilt model, so the accuracy is not very good. It will hopefully be replaced with a better solution in the future.

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 74,
"android.injected.version.name" => "1.51.2",
"android.injected.version.code" => 75,
"android.injected.version.name" => "1.52.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -246,5 +246,21 @@
"permission_onboarding_log_out": "Log out",
"login_form_next_button": "Next",
"album_thumbnail_shared_by": "Shared by {}",
"album_thumbnail_owned": "Owned"
"album_thumbnail_owned": "Owned",
"curated_object_page_title": "Things",
"curated_location_page_title": "Places",
"search_page_view_all_button": "View all",
"search_page_your_activity": "Your activity",
"search_page_favorites": "Favorites",
"search_page_videos": "Videos",
"all_videos_page_title": "Videos",
"recently_added_page_title": "Recently Added",
"motion_photos_page_title": "Motion Photos",
"search_page_motion_photos": "Motion Photos",
"search_page_recently_added": "Recently added",
"search_page_categories": "Categories",
"search_page_screenshots": "Screenshots",
"search_page_selfies": "Selfies",
"search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ",
"search_suggestion_list_smart_search_hint_2": "m:your-search-term"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:integration_test/integration_test.dart';
import 'package:isar/isar.dart';
@@ -35,9 +34,7 @@ class ImmichTestHelper {
}
static Future<void> loadApp(WidgetTester tester) async {
// Clear all data from Hive
await Hive.deleteFromDisk();
await app.openBoxes();
await EasyLocalization.ensureInitialized();
// Clear all data from Isar (reuse existing instance if available)
final db = Isar.getInstance() ?? await app.loadDb();
await Store.clear();
@@ -65,12 +62,13 @@ void immichWidgetTest(
}
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 120),
}) async {
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 120),
}) async {
bool found = false;
final timer = Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
final timer =
Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
while (found != true) {
await tester.pump();
found = tester.any(finder);

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.51.2"
version_number: "1.52.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -25,6 +25,7 @@ import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
@@ -42,35 +43,24 @@ import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'constants/hive_box.dart';
void main() async {
await initApp();
WidgetsFlutterBinding.ensureInitialized();
final db = await loadDb();
await initApp();
await migrateHiveToStoreIfNecessary();
await migrateJsonCacheIfNecessary();
await migrateDatabaseIfNeeded(db);
runApp(getMainWidget(db));
}
Future<void> openBoxes() async {
await Future.wait([
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(hiveGithubReleaseInfoBox),
Hive.openBox(userSettingInfoBox),
EasyLocalization.ensureInitialized(),
]);
}
Future<void> initApp() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
Hive.registerAdapter(ImmichLoggerMessageAdapter());
await openBoxes();
await EasyLocalization.ensureInitialized();
if (kReleaseMode && Platform.isAndroid) {
try {
@@ -82,7 +72,7 @@ Future<void> initApp() async {
}
// Initialize Immich Logger Service
ImmichLogger().init();
ImmichLogger();
var log = Logger("ImmichErrorLogger");
@@ -108,6 +98,7 @@ Future<Isar> loadDb() async {
UserSchema,
BackupAlbumSchema,
DuplicatedAssetSchema,
LoggerMessageSchema,
],
directory: dir.path,
maxSizeMiB: 256,
@@ -174,6 +165,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive");
ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
ImmichLogger().flush();
ref.watch(websocketProvider.notifier).disconnect();
ref.watch(backupProvider.notifier).cancelBackup();

View File

@@ -265,7 +265,7 @@ class AlbumService {
Future<bool> deleteAlbum(Album album) async {
try {
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
final userId = Store.get(StoreKey.currentUser).isarId;
if (album.owner.value?.isarId == userId) {
await _apiService.albumApi.deleteAlbum(album.remoteId!);
}

View File

@@ -53,7 +53,7 @@ class AlbumThumbnailCard extends StatelessWidget {
// Add the owner name to the subtitle
String? owner;
if (showOwner) {
if (album.ownerId == Store.get(StoreKey.userRemoteId)) {
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr();
} else if (album.ownerName != null) {
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);

View File

@@ -2,10 +2,9 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -21,7 +20,6 @@ class AlbumThumbnailListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var cardSize = 68.0;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
@@ -50,7 +48,9 @@ class AlbumThumbnailListTile extends StatelessWidget {
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
httpHeaders: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),

View File

@@ -17,7 +17,7 @@ class SharingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
final userId = store.Store.get(store.StoreKey.userRemoteId);
final userId = store.Store.get(store.StoreKey.currentUser).id;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
useEffect(

View File

@@ -0,0 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
var settings = ref.watch(appSettingsServiceProvider);
final layout = AssetGridLayoutParameters(
settings.getSetting(AppSettingsEnum.tilesPerRow),
settings.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
);
return RenderList.fromAssets(assets, layout);
});

View File

@@ -4,16 +4,15 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@@ -47,7 +46,6 @@ class GalleryViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final Box<dynamic> box = Hive.box(userInfoBox);
final settings = ref.watch(appSettingsServiceProvider);
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
@@ -57,7 +55,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false);
late Offset localPosition;
final authToken = 'Bearer ${box.get(accessTokenKey)}';
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
showAppBar.addListener(() {
// Change to and from immersive mode, hiding navigation and app bar

View File

@@ -1,13 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:chewie/chewie.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
@@ -54,17 +53,15 @@ class VideoViewerPage extends HookConsumerWidget {
}
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey);
final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
return Stack(
children: [
VideoThumbnailPlayer(
url: videoUrl,
jwtToken: jwtToken,
jwtToken: Store.get(StoreKey.accessToken),
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,

View File

@@ -8,16 +8,13 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@@ -317,7 +314,6 @@ class BackgroundService {
debugPrint(error.toString());
return false;
} finally {
await Hive.close();
releaseLock();
}
case "systemStop":
@@ -332,17 +328,9 @@ class BackgroundService {
Future<bool> _onAssetsChanged() async {
final Isar db = await loadDb();
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
await Future.wait([
Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(userSettingInfoBox),
]);
ApiService apiService = ApiService();
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
apiService.setAccessToken(Store.get(StoreKey.accessToken));
BackupService backupService = BackupService(apiService, db);
AppSettingsService settingsService = AppSettingsService();
@@ -387,7 +375,7 @@ class BackgroundService {
db.backupAlbums.deleteAllSync(toDelete);
db.backupAlbums.putAllSync(toUpsert);
});
} else if (Store.get(StoreKey.backupFailedSince) == null) {
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
Store.put(StoreKey.backupFailedSince, DateTime.now());
return false;
}
@@ -529,7 +517,7 @@ class BackgroundService {
} else if (value == 5) {
return false;
}
final DateTime? failedSince = Store.get(StoreKey.backupFailedSince);
final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
if (failedSince == null) {
return false;
}

View File

@@ -15,6 +15,7 @@ class BackUpState {
final double progressInPercentage;
final CancellationToken cancelToken;
final ServerInfoResponseDto serverInfo;
final bool autoBackup;
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
@@ -40,6 +41,7 @@ class BackUpState {
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
required this.autoBackup,
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
@@ -58,6 +60,7 @@ class BackUpState {
double? progressInPercentage,
CancellationToken? cancelToken,
ServerInfoResponseDto? serverInfo,
bool? autoBackup,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
@@ -75,6 +78,7 @@ class BackUpState {
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
@@ -92,7 +96,7 @@ class BackUpState {
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -106,6 +110,7 @@ class BackUpState {
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
@@ -128,6 +133,7 @@ class BackUpState {
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
autoBackup.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^

View File

@@ -1,9 +1,7 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
@@ -41,10 +39,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
allAssetsInDatabase: const [],
progressInPercentage: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: false,
backupRequireWifi: true,
backupRequireCharging: false,
backupTriggerDelay: 5000,
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging:
Store.get(StoreKey.backupRequireCharging, false),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
serverInfo: ServerInfoResponseDto(
diskAvailable: "0",
diskAvailableRaw: 0,
@@ -122,6 +122,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
@@ -163,14 +168,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
await Future.wait([
Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi),
Store.put(
StoreKey.backupRequireCharging,
state.backupRequireCharging,
),
Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay),
]);
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
await Store.put(
StoreKey.backupRequireCharging,
state.backupRequireCharging,
);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
@@ -544,7 +547,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Future<void> _resumeBackup() async {
// Check if user is login
final accessKey = Hive.box(userInfoBox).get(accessTokenKey);
final accessKey = Store.tryGet(StoreKey.accessToken);
// User has been logged out return
if (accessKey == null || !_authState.isAuthenticated) {
@@ -553,8 +556,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Check if this device is enable backup by the user
if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
_authState.deviceInfo.isAutoBackup) {
if (state.autoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
log.info("[_resumeBackup] Backup is already in progress - abort");
@@ -570,7 +572,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
log.info("[_resumeBackup] Start back up");
await startBackupProcess();
}
return;
}
@@ -603,9 +604,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
backupProgress: BackUpProgressEnum.inBackground,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
backupRequireWifi: Store.get(StoreKey.backupRequireWifi),
backupRequireCharging: Store.get(StoreKey.backupRequireCharging),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay),
);
// assumes the background service is currently running
// if true, waits until it has stopped to start the backup

View File

@@ -5,13 +5,12 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@@ -38,7 +37,7 @@ class BackupService {
BackupService(this._apiService, this._db);
Future<List<String>?> getDeviceBackupAsset() async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final String deviceId = Store.get(StoreKey.deviceId);
try {
return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
@@ -173,7 +172,7 @@ class BackupService {
}
final Set<String> existing = {};
try {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final String deviceId = Store.get(StoreKey.deviceId);
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetApi.checkExistingAssets(
CheckExistingAssetsDto(
@@ -204,8 +203,8 @@ class BackupService {
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
final String deviceId = Store.get(StoreKey.deviceId);
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
File? file;
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
@@ -236,15 +235,15 @@ class BackupService {
),
);
var box = Hive.box(userInfoBox);
var req = MultipartRequest(
'POST',
Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) =>
uploadProgressCb(bytes, totalBytes)),
);
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.headers["Authorization"] =
"Bearer ${Store.get(StoreKey.accessToken)}";
req.headers["Transfer-Encoding"] = "chunked";
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
@@ -365,31 +364,6 @@ class BackupService {
return "OTHER";
}
}
Future<DeviceInfoResponseDto> setAutoBackup(
bool status,
String deviceId,
DeviceTypeEnum deviceType,
) async {
try {
var updatedDeviceInfo = await _apiService.deviceInfoApi.upsertDeviceInfo(
UpsertDeviceInfoDto(
deviceId: deviceId,
deviceType: deviceType,
isAutoBackup: status,
),
);
if (updatedDeviceInfo == null) {
throw Exception("Error updating device info");
}
return updatedDeviceInfo;
} catch (e) {
debugPrint("Error setAutoBackup: ${e.toString()}");
throw Error();
}
}
}
class MultipartRequest extends http.MultipartRequest {

View File

@@ -10,9 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/error_backup_list.provide
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
@@ -26,7 +24,6 @@ class BackupControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState authenticationState = ref.watch(authenticationProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final appRefreshDisabled =
@@ -102,11 +99,11 @@ class BackupControllerPage extends HookConsumerWidget {
}
ListTile buildAutoBackupController() {
var backUpOption = authenticationState.deviceInfo.isAutoBackup
final isAutoBackup = backupState.autoBackup;
final backUpOption = isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
var backupBtnText = authenticationState.deviceInfo.isAutoBackup
final backupBtnText = isAutoBackup
? "backup_controller_page_turn_off".tr()
: "backup_controller_page_turn_on".tr();
return ListTile(
@@ -134,17 +131,9 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () {
if (isAutoBackup) {
ref
.read(authenticationProvider.notifier)
.setAutoBackup(false);
} else {
ref
.read(authenticationProvider.notifier)
.setAutoBackup(true);
}
},
onPressed: () => ref
.read(backupProvider.notifier)
.setAutoBackup(!isAutoBackup),
child: Text(
backupBtnText,
style: const TextStyle(

View File

@@ -3,9 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/favorite/ui/favorite_image.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key);
@@ -22,46 +20,14 @@ class FavoritesPage extends HookConsumerWidget {
automaticallyImplyLeading: false,
title: const Text(
'favorites_page_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildImageGrid() {
final appSettingService = ref.watch(appSettingsServiceProvider);
if (ref.watch(favoriteAssetProvider).isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(
BuildContext context,
int index,
) {
return FavoriteImage(
ref.watch(favoriteAssetProvider)[index],
ref.watch(favoriteAssetProvider),
);
},
childCount: ref.watch(favoriteAssetProvider).length,
),
),
);
}
return const SliverToBoxAdapter();
}
return Scaffold(
appBar: buildAppBar(),
body: CustomScrollView(
slivers: [buildImageGrid()],
body: ImmichAssetGrid(
assets: ref.watch(favoriteAssetProvider),
),
);
}

View File

@@ -1,300 +1,106 @@
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'group_divider_title.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<Asset>,
);
class ImmichAssetGridState extends State<ImmichAssetGrid> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
bool _scrolling = false;
final Set<int> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
.toSet();
}
void _callSelectionListener(bool selectionActive) {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
}
_callSelectionListener(true);
});
}
void _deselectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
}
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
void _deselectAll() {
setState(() {
_selectedAssets.clear();
});
_callSelectionListener(false);
}
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
Widget _buildThumbnailOrPlaceholder(
Asset asset,
bool placeholder,
) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: widget.allAssets,
multiselectEnabled: widget.selectionActive,
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]),
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
);
}
Widget _buildAssetRow(
BuildContext context,
RenderAssetGridRow row,
bool scrolling,
) {
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.mapIndexed((int index, Asset asset) {
bool last = asset.id == row.assets.last.id;
return Container(
key: Key("asset-${asset.id}"),
width: size * row.widthDistribution[index],
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
},
);
}
Widget _buildTitle(
BuildContext context,
String title,
List<Asset> assets,
) {
return GroupDividerTitle(
text: title,
multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets),
onDeselect: () => _deselectAssets(assets),
selected: _allAssetsSelected(assets),
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
title,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.displayLarge?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList.elements[position];
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, _scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = widget.renderList.elements[pos].date;
return Text(
DateFormat.yMMMM().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildMultiSelectIndicator() {
return DisableMultiSelectButton(
onPressed: () => _deselectAll(),
selectedItemCount: _selectedAssets.length,
);
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.allAssets.length >= 20;
void dragScrolling(bool active) {
setState(() {
_scrolling = active;
});
}
final listWidget = ScrollablePositionedList.builder(
padding: const EdgeInsets.only(
bottom: 220,
),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: widget.renderList.elements.length,
addRepaintBoundaries: true,
);
if (!useDragScrolling) {
return listWidget;
}
return DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
);
}
@override
void didUpdateWidget(ImmichAssetGrid oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.selectionActive) {
setState(() {
_selectedAssets.clear();
});
}
}
Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll();
return false;
}
return true;
}
@override
void initState() {
super.initState();
scrollToTopNotifierProvider.addListener(_scrollToTop);
}
@override
void dispose() {
scrollToTopNotifierProvider.removeListener(_scrollToTop);
super.dispose();
}
void _scrollToTop() {
// for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar
_itemScrollController.jumpTo(
index: 0,
);
_itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 200),
);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: onWillPop,
child: Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
),
);
}
}
class ImmichAssetGrid extends StatefulWidget {
final RenderList renderList;
final int assetsPerRow;
class ImmichAssetGrid extends HookConsumerWidget {
final int? assetsPerRow;
final double margin;
final bool showStorageIndicator;
final bool? showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final List<Asset> allAssets;
final List<Asset> assets;
final RenderList? renderList;
final Future<void> Function()? onRefresh;
const ImmichAssetGrid({
super.key,
required this.renderList,
required this.allAssets,
required this.assetsPerRow,
required this.showStorageIndicator,
required this.assets,
this.onRefresh,
this.renderList,
this.assetsPerRow,
this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridState();
Widget build(BuildContext context, WidgetRef ref) {
var settings = ref.watch(appSettingsServiceProvider);
final renderListFuture = ref.watch(renderListProvider(assets));
// Needs to suppress hero animations when navigating to this widget
final enableHeroAnimations = useState(false);
// Wait for transition to complete, then re-enable
ModalRoute.of(context)?.animation?.addListener(() {
// If we've already enabled, we are done
if (enableHeroAnimations.value) {
return;
}
final animation = ModalRoute.of(context)?.animation;
if (animation != null) {
// When the animation is complete, re-enable hero animations
enableHeroAnimations.value = animation.isCompleted;
}
});
Future<bool> onWillPop() async {
enableHeroAnimations.value = false;
return true;
}
if (renderList != null) {
return WillPopScope(
onWillPop: onWillPop,
child: HeroMode(
enabled: enableHeroAnimations.value,
child: ImmichAssetGridView(
allAssets: assets,
onRefresh: onRefresh,
assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener,
showStorageIndicator: showStorageIndicator ??
settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList!,
margin: margin,
selectionActive: selectionActive,
),
),
);
}
return renderListFuture.when(
data: (renderList) => WillPopScope(
onWillPop: onWillPop,
child: HeroMode(
enabled: enableHeroAnimations.value,
child: ImmichAssetGridView(
allAssets: assets,
onRefresh: onRefresh,
assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener,
showStorageIndicator: showStorageIndicator ??
settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList,
margin: margin,
selectionActive: selectionActive,
),
),
),
error: (err, stack) => Center(child: Text("$err")),
loading: () => const Center(
child: ImmichLoadingIndicator(),
),
);
}
}

View File

@@ -0,0 +1,304 @@
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'group_divider_title.dart';
import 'disable_multi_select_button.dart';
import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<Asset>,
);
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
bool _scrolling = false;
final Set<int> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
.toSet();
}
void _callSelectionListener(bool selectionActive) {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
}
_callSelectionListener(true);
});
}
void _deselectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
}
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
void _deselectAll() {
setState(() {
_selectedAssets.clear();
});
_callSelectionListener(false);
}
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
Widget _buildThumbnailOrPlaceholder(
Asset asset,
bool placeholder,
) {
if (placeholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return ThumbnailImage(
asset: asset,
assetList: widget.allAssets,
multiselectEnabled: widget.selectionActive,
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]),
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
);
}
Widget _buildAssetRow(
BuildContext context,
RenderAssetGridRow row,
bool scrolling,
) {
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth / widget.assetsPerRow -
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.mapIndexed((int index, Asset asset) {
bool last = asset.id == row.assets.last.id;
return Container(
key: Key("asset-${asset.id}"),
width: size * row.widthDistribution[index],
height: size,
margin: EdgeInsets.only(
top: widget.margin,
right: last ? 0.0 : widget.margin,
),
child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
);
},
);
}
Widget _buildTitle(
BuildContext context,
String title,
List<Asset> assets,
) {
return GroupDividerTitle(
text: title,
multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets),
onDeselect: () => _deselectAssets(assets),
selected: _allAssetsSelected(assets),
);
}
Widget _buildMonthTitle(BuildContext context, String title) {
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
title,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.displayLarge?.color,
),
),
);
}
Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList.elements[position];
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!);
} else if (item.type == RenderAssetGridElementType.monthTitle) {
return _buildMonthTitle(c, item.title!);
} else if (item.type == RenderAssetGridElementType.assetRow) {
return _buildAssetRow(c, item.assetRow!, _scrolling);
}
return const Text("Invalid widget type!");
}
Text _labelBuilder(int pos) {
final date = widget.renderList.elements[pos].date;
return Text(
DateFormat.yMMMM().format(date),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildMultiSelectIndicator() {
return DisableMultiSelectButton(
onPressed: () => _deselectAll(),
selectedItemCount: _selectedAssets.length,
);
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.allAssets.length >= 20;
void dragScrolling(bool active) {
setState(() {
_scrolling = active;
});
}
final listWidget = ScrollablePositionedList.builder(
padding: const EdgeInsets.only(
bottom: 220,
),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController,
itemCount: widget.renderList.elements.length,
addRepaintBoundaries: true,
);
final child = useDragScrolling
? DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
)
: listWidget;
return widget.onRefresh == null
? child
: RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
}
@override
void didUpdateWidget(ImmichAssetGridView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.selectionActive) {
setState(() {
_selectedAssets.clear();
});
}
}
Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll();
return false;
}
return true;
}
@override
void initState() {
super.initState();
scrollToTopNotifierProvider.addListener(_scrollToTop);
}
@override
void dispose() {
scrollToTopNotifierProvider.removeListener(_scrollToTop);
super.dispose();
}
void _scrollToTop() {
// for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar
_itemScrollController.jumpTo(
index: 0,
);
_itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 200),
);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: onWillPop,
child: Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
),
);
}
}
class ImmichAssetGridView extends StatefulWidget {
final RenderList renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final List<Asset> allAssets;
final Future<void> Function()? onRefresh;
const ImmichAssetGridView({
super.key,
required this.renderList,
required this.allAssets,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
this.onRefresh,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridViewState();
}
}

View File

@@ -1,10 +1,7 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -13,7 +10,6 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
@override
@@ -29,8 +25,8 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider);
@@ -47,29 +43,13 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
},
);
} else {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var dummy = Random().nextInt(1024);
return InkWell(
onTap: () {
Scaffold.of(context).openDrawer();
},
child: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: const UserCircleAvatar(
radius: 18,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage.memoryNetwork(
fit: BoxFit.cover,
placeholder: kTransparentImage,
width: 33,
height: 33,
image:
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
fadeInDuration: const Duration(milliseconds: 200),
imageErrorBuilder: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
),
),
size: 33,
),
);
}

View File

@@ -1,16 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class ProfileDrawerHeader extends HookConsumerWidget {
const ProfileDrawerHeader({
@@ -19,31 +15,15 @@ class ProfileDrawerHeader extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState authState = ref.watch(authenticationProvider);
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
var dummy = Random().nextInt(1024);
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
buildUserProfileImage() {
var userImage = CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
var userImage = const UserCircleAvatar(
radius: 35,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage.memoryNetwork(
fit: BoxFit.cover,
placeholder: kTransparentImage,
width: 66,
height: 66,
image:
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
fadeInDuration: const Duration(milliseconds: 200),
imageErrorBuilder: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
),
),
size: 66,
);
if (authState.profileImagePath.isEmpty) {

View File

@@ -0,0 +1,44 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class UserCircleAvatar extends ConsumerWidget {
final double radius;
final double size;
const UserCircleAvatar({super.key, required this.radius, required this.size});
@override
Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState authState = ref.watch(authenticationProvider);
var profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${authState.userId}?d=${Random().nextInt(1024)}';
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: radius,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage(
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
width: size,
height: size,
image: NetworkImage(
profileImageUrl,
headers: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
},
),
fadeInDuration: const Duration(milliseconds: 200),
imageErrorBuilder: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
),
),
);
}
}

View File

@@ -43,6 +43,7 @@ class HomePage extends HookConsumerWidget {
final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
useEffect(
() {
@@ -182,6 +183,22 @@ class HomePage extends HookConsumerWidget {
}
}
Future<void> refreshAssets() async {
debugPrint("refreshCount.value ${refreshCount.value}");
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (fullRefresh) {
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 2), () {
refreshCount.value = 0;
});
}
}
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () {
tipOneOpacity.value = 1;
@@ -234,13 +251,14 @@ class HomePage extends HookConsumerWidget {
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
),
if (selectionEnabledHook.value)
SafeArea(

View File

@@ -11,7 +11,6 @@ class AuthenticationState {
final bool isAdmin;
final bool shouldChangePassword;
final String profileImagePath;
final DeviceInfoResponseDto deviceInfo;
AuthenticationState({
required this.deviceId,
required this.deviceType,
@@ -23,7 +22,6 @@ class AuthenticationState {
required this.isAdmin,
required this.shouldChangePassword,
required this.profileImagePath,
required this.deviceInfo,
});
AuthenticationState copyWith({
@@ -37,7 +35,6 @@ class AuthenticationState {
bool? isAdmin,
bool? shouldChangePassword,
String? profileImagePath,
DeviceInfoResponseDto? deviceInfo,
}) {
return AuthenticationState(
deviceId: deviceId ?? this.deviceId,
@@ -50,13 +47,12 @@ class AuthenticationState {
isAdmin: isAdmin ?? this.isAdmin,
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
profileImagePath: profileImagePath ?? this.profileImagePath,
deviceInfo: deviceInfo ?? this.deviceInfo,
);
}
@override
String toString() {
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)';
}
@override
@@ -73,8 +69,7 @@ class AuthenticationState {
other.lastName == lastName &&
other.isAdmin == isAdmin &&
other.shouldChangePassword == shouldChangePassword &&
other.profileImagePath == profileImagePath &&
other.deviceInfo == deviceInfo;
other.profileImagePath == profileImagePath;
}
@override
@@ -88,7 +83,6 @@ class AuthenticationState {
lastName.hashCode ^
isAdmin.hashCode ^
shouldChangePassword.hashCode ^
profileImagePath.hashCode ^
deviceInfo.hashCode;
profileImagePath.hashCode;
}
}

View File

@@ -2,13 +2,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@@ -19,7 +15,6 @@ import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier(
this._deviceInfoService,
this._backupService,
this._apiService,
) : super(
AuthenticationState(
@@ -33,19 +28,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
isAdmin: false,
shouldChangePassword: false,
isAuthenticated: false,
deviceInfo: DeviceInfoResponseDto(
id: 0,
userId: "",
deviceId: "",
deviceType: DeviceTypeEnum.ANDROID,
createdAt: "",
isAutoBackup: false,
),
),
);
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final ApiService _apiService;
Future<bool> login(
@@ -91,11 +77,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
try {
await Future.wait([
_apiService.authenticationApi.logout(),
Hive.box(userInfoBox).delete(accessTokenKey),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.userRemoteId),
Store.delete(StoreKey.currentUser),
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
Store.delete(StoreKey.accessToken),
]);
state = state.copyWith(isAuthenticated: false);
@@ -107,18 +91,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
}
setAutoBackup(bool backupState) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
DeviceTypeEnum deviceType = deviceInfo["deviceType"];
DeviceInfoResponseDto updatedDeviceInfo =
await _backupService.setAutoBackup(backupState, deviceId, deviceType);
state = state.copyWith(deviceInfo: updatedDeviceInfo);
}
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
@@ -157,14 +129,12 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
if (userResponseDto != null) {
var userInfoHiveBox = await Hive.openBox(userInfoBox);
var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken);
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
Store.put(StoreKey.userRemoteId, userResponseDto.id);
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
state = state.copyWith(
isAuthenticated: true,
@@ -178,40 +148,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
deviceId: deviceInfo["deviceId"],
deviceType: deviceInfo["deviceType"],
);
// Save login info to local storage
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
HiveSavedLoginInfo(
email: "",
password: "",
serverUrl: serverUrl,
accessToken: accessToken,
),
);
}
// Register device info
try {
DeviceInfoResponseDto? deviceInfo =
await _apiService.deviceInfoApi.upsertDeviceInfo(
UpsertDeviceInfoDto(
deviceId: state.deviceId,
deviceType: state.deviceType,
),
);
if (deviceInfo == null) {
debugPrint('Device Info Response is null');
return false;
}
state = state.copyWith(deviceInfo: deviceInfo);
} catch (e) {
debugPrint("ERROR Register Device Info: $e");
return e is ApiException && e.innerException is SocketException;
}
return true;
}
}
@@ -220,7 +157,6 @@ final authenticationProvider =
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
);
});

View File

@@ -1,14 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/oauth.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -63,8 +61,7 @@ class LoginForm extends HookConsumerWidget {
try {
isLoadingServer.value = true;
final endpoint =
await apiService.resolveAndSetEndpoint(serverUrl);
final endpoint = await apiService.resolveAndSetEndpoint(serverUrl);
final loginConfig = await apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: serverUrl),
@@ -104,15 +101,10 @@ class LoginForm extends HookConsumerWidget {
useEffect(
() {
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.get(savedLoginInfoKey);
if (loginInfo != null) {
usernameController.text = loginInfo.email;
passwordController.text = loginInfo.password;
serverEndpointController.text = loginInfo.serverUrl;
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null) {
serverEndpointController.text = serverUrl;
}
return null;
},
[],
@@ -133,11 +125,11 @@ class LoginForm extends HookConsumerWidget {
try {
final isAuthenticated =
await ref.read(authenticationProvider.notifier).login(
usernameController.text,
passwordController.text,
serverEndpointController.text.trim(),
);
await ref.read(authenticationProvider.notifier).login(
usernameController.text,
passwordController.text,
serverEndpointController.text.trim(),
);
if (isAuthenticated) {
// Resume backup (if enable) then navigate
if (ref.read(authenticationProvider).shouldChangePassword &&
@@ -283,61 +275,61 @@ class LoginForm extends HookConsumerWidget {
onSubmit: login,
),
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const Padding(
padding: EdgeInsets.only(top: 18.0),
child: SizedBox(
width: 24,
height: 24,
child: FittedBox(
child: CircularProgressIndicator(
strokeWidth: 2,
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const Padding(
padding: EdgeInsets.only(top: 18.0),
child: SizedBox(
width: 24,
height: 24,
child: FittedBox(
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Divider(
color:
Brightness.dark == Theme.of(context).brightness
? Colors.white
: Colors.black,
),
),
child: Divider(
color:
Brightness.dark == Theme.of(context).brightness
? Colors.white
: Colors.black,
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
),
],
],
],
),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('Back'),
),
),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('Back'),
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null
? buildSelectServer()
: buildLogin();
final serverSelectionOrLogin =
serverEndpoint.value == null ? buildSelectServer() : buildLogin();
return LayoutBuilder(
builder: (context, constraints) {
@@ -462,6 +454,10 @@ class EmailInput extends StatelessWidget {
labelText: 'login_form_label_email'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_email_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
@@ -495,6 +491,10 @@ class PasswordInput extends StatelessWidget {
labelText: 'login_form_label_password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
@@ -545,7 +545,6 @@ class OAuthLoginButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor.withAlpha(230),

View File

@@ -0,0 +1,15 @@
/// A wrapper for [CuratedLocationsResponseDto] objects
/// and [CuratedObjectsResponseDto] to be displayed in
/// a view
class CuratedContent {
/// The label to show associated with this curated object
final String label;
/// The id to lookup the asset from the server
final String id;
CuratedContent({
required this.id,
required this.label,
});
}

View File

@@ -0,0 +1,29 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
final allMotionPhotosProvider = FutureProvider<List<Asset>>( (ref) async {
final search = await ref.watch(apiServiceProvider).searchApi.search(
motion: true,
);
if (search == null) {
return [];
}
return ref.watch(dbProvider)
.assets
.getAllByRemoteId(
search.assets.items.map((e) => e.id),
);
/// This works offline, but we use the above
/*
return ref.watch(dbProvider).assets
.filter()
.livePhotoVideoIdIsNotNull()
.findAll();
*/
});

View File

@@ -0,0 +1,28 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
final allVideoAssetsProvider = FutureProvider<List<Asset>>( (ref) async {
final search = await ref.watch(apiServiceProvider).searchApi.search(
type: 'VIDEO',
);
if (search == null) {
return [];
}
return ref.watch(dbProvider)
.assets
.getAllByRemoteId(
search.assets.items.map((e) => e.id),
);
/// This works offline, but we use the above
/*
return ref.watch(dbProvider).assets
.filter()
.durationInSecondsGreaterThan(0)
.findAll();
*/
});

View File

@@ -0,0 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
final recentlyAddedProvider = FutureProvider<List<Asset>>( (ref) async {
final search = await ref.watch(apiServiceProvider).searchApi.search(
recent: true,
);
if (search == null) {
return [];
}
return ref.watch(dbProvider)
.assets
.getAllByRemoteId(
search.assets.items.map((e) => e.id),
);
});

View File

@@ -1,10 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
@@ -20,7 +18,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
final SearchService _searchService;
void search(String searchTerm) async {
void search(String searchTerm, {bool clipEnable = true}) async {
state = state.copyWith(
searchResult: [],
isError: false,
@@ -28,7 +26,10 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
isSuccess: false,
);
List<Asset>? assets = await _searchService.searchAsset(searchTerm);
List<Asset>? assets = await _searchService.searchAsset(
searchTerm,
clipEnable: clipEnable,
);
if (assets != null) {
state = state.copyWith(
@@ -55,15 +56,6 @@ final searchResultPageProvider =
});
final searchRenderListProvider = FutureProvider((ref) {
var settings = ref.watch(appSettingsServiceProvider);
final assets = ref.watch(searchResultPageProvider).searchResult;
final layout = AssetGridLayoutParameters(
settings.getSetting(AppSettingsEnum.tilesPerRow),
settings.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
);
return RenderList.fromAssets(assets, layout);
return ref.watch(renderListProvider(assets));
});

View File

@@ -29,16 +29,21 @@ class SearchService {
}
}
Future<List<Asset>?> searchAsset(String searchTerm) async {
Future<List<Asset>?> searchAsset(
String searchTerm, {
bool clipEnable = true,
}) async {
// TODO search in local DB: 1. when offline, 2. to find local assets
try {
final List<AssetResponseDto>? results = await _apiService.assetApi
.searchAsset(SearchAssetDto(searchTerm: searchTerm));
final SearchResponseDto? results = await _apiService.searchApi.search(
query: searchTerm,
clip: clipEnable,
);
if (results == null) {
return null;
}
// TODO local DB might be out of date; add assets not yet in DB?
return _db.assets.getAllByRemoteId(results.map((e) => e.id));
return _db.assets.getAllByRemoteId(results.assets.items.map((e) => e.id));
} catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null;

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
class CuratedRow extends StatelessWidget {
final List<CuratedContent> content;
final double imageSize;
/// Callback with the content and the index when tapped
final Function(CuratedContent, int)? onTap;
const CuratedRow({
super.key,
required this.content,
this.imageSize = 200,
this.onTap,
});
@override
Widget build(BuildContext context) {
// Guard empty [content]
if (content.isEmpty) {
// Return empty thumbnail
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: imageSize,
height: imageSize,
child: ThumbnailWithInfo(
textInfo: '',
onTap: () {},
),
),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (context, index) {
final object = content[index];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
return SizedBox(
width: imageSize,
height: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, index),
),
),
);
},
itemCount: content.length,
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
class ExploreGrid extends StatelessWidget {
final List<CuratedContent> curatedContent;
const ExploreGrid({
super.key,
required this.curatedContent,
});
@override
Widget build(BuildContext context) {
if (curatedContent.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: 100,
width: 100,
child: ThumbnailWithInfo(
textInfo: '',
onTap: () {},
),
),
);
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 140,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemBuilder: (context, index) {
final content = curatedContent[index];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: content.label,
borderRadius: 0,
onTap: () {
AutoRouter.of(context).push(
SearchResultRoute(searchTerm: 'm:${content.label}'),
);
},
);
},
itemCount: curatedContent.length,
);
}
}

View File

@@ -30,7 +30,10 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
},
icon: const Icon(Icons.arrow_back_ios_rounded),
)
: const Icon(Icons.search_rounded),
: const Icon(
Icons.search_rounded,
size: 20,
),
title: TextField(
controller: searchTermController,
focusNode: searchFocusNode,
@@ -55,6 +58,8 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
hintText: 'search_bar_hint'.tr(),
hintStyle: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
fontWeight: FontWeight.w500,
fontSize: 14,
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class SearchResultGrid extends HookConsumerWidget {
const SearchResultGrid({super.key, required this.assets});
final List<Asset> assets;
@override
Widget build(BuildContext context, WidgetRef ref) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 1,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemCount: assets.length,
itemBuilder: (context, index) {
final asset = assets[index];
return ThumbnailImage(
asset: asset,
assetList: assets,
useGrayBoxPlaceholder: true,
);
},
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
@@ -12,6 +13,7 @@ class SearchSuggestionList extends ConsumerWidget {
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
final searchSuggestion =
ref.watch(searchPageStateProvider).searchSuggestion;
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Container(
color: searchTerm.isEmpty
@@ -19,13 +21,38 @@ class SearchSuggestionList extends ConsumerWidget {
: Theme.of(context).scaffoldBackgroundColor,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(
color: isDarkTheme ? Colors.grey[800] : Colors.grey[100],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: 'search_suggestion_list_smart_search_hint_1'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: 'search_suggestion_list_smart_search_hint_2'.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
)
],
),
),
),
),
),
SliverFillRemaining(
hasScrollBody: true,
child: ListView.builder(
itemBuilder: ((context, index) {
return ListTile(
onTap: () {
onSubmitted(searchSuggestion[index]);
onSubmitted("m:${searchSuggestion[index]}");
},
title: Text(searchSuggestion[index]),
);

View File

@@ -1,14 +1,16 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
// ignore: must_be_immutable
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({
ThumbnailWithInfo({
Key? key,
required this.textInfo,
this.imageUrl,
this.noImageIcon,
this.borderRadius = 10,
required this.onTap,
}) : super(key: key);
@@ -16,72 +18,77 @@ class ThumbnailWithInfo extends StatelessWidget {
final String? imageUrl;
final Function onTap;
final IconData? noImageIcon;
double borderRadius;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var textAndIconColor = isDarkMode ? Colors.grey[100] : Colors.grey[700];
return GestureDetector(
onTap: () {
onTap();
},
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
border: Border.all(
color: isDarkMode ? Colors.grey[800]! : Colors.grey[400]!,
width: 1,
),
),
child: imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
width: 250,
height: 250,
fit: BoxFit.cover,
imageUrl: imageUrl!,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
)
: Center(
child: Icon(
noImageIcon ?? Icons.not_listed_location,
color: textAndIconColor,
),
),
),
Positioned(
bottom: 12,
left: 14,
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
child: Text(
textInfo,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
),
child: imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: CachedNetworkImage(
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
imageUrl: imageUrl!,
httpHeaders: {
"Authorization":
"Bearer ${Store.get(StoreKey.accessToken)}"
},
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
)
: Center(
child: Icon(
noImageIcon ?? Icons.not_listed_location,
color: textAndIconColor,
),
),
),
),
],
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
color: Colors.white,
gradient: LinearGradient(
begin: FractionalOffset.topCenter,
end: FractionalOffset.bottomCenter,
colors: [
Colors.grey.withOpacity(0.0),
textInfo == ''
? Colors.black.withOpacity(0.1)
: Colors.black.withOpacity(0.5),
],
stops: const [0.0, 1.0],
),
),
),
Positioned(
bottom: 12,
left: 14,
child: Text(
textInfo == '' ? textInfo : textInfo.capitalizeFirstLetter(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
);
}

View File

@@ -0,0 +1,35 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/all_motion_photos.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class AllMotionPhotosPage extends HookConsumerWidget {
const AllMotionPhotosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final motionPhotos = ref.watch(allMotionPhotosProvider);
return Scaffold(
appBar: AppBar(
title: const Text('motion_photos_page_title').tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: motionPhotos.when(
data: (assets) => ImmichAssetGrid(
assets: assets,
),
error: (e, s) => Text(e.toString()),
loading: () => const Center(
child: ImmichLoadingIndicator(),
),
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/all_video_assets.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class AllVideosPage extends HookConsumerWidget {
const AllVideosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videos = ref.watch(allVideoAssetsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('all_videos_page_title').tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: videos.when(
data: (assets) => ImmichAssetGrid(
assets: assets,
),
error: (e, s) => Text(e.toString()),
loading: () => const Center(
child: ImmichLoadingIndicator(),
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:openapi/api.dart';
class CuratedLocationPage extends HookConsumerWidget {
const CuratedLocationPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<CuratedLocationsResponseDto>> curatedLocation =
ref.watch(getCuratedLocationProvider);
return Scaffold(
appBar: AppBar(
title: Text(
'curated_location_page_title',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
).tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedLocation.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(
child: Text('Error: $err'),
),
data: (curatedLocations) => ExploreGrid(
curatedContent: curatedLocations
.map(
(l) => CuratedContent(
label: l.city,
id: l.id,
),
)
.toList(),
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
import 'package:openapi/api.dart';
class CuratedObjectPage extends HookConsumerWidget {
const CuratedObjectPage({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
ref.watch(getCuratedObjectProvider);
return Scaffold(
appBar: AppBar(
title: Text(
'curated_object_page_title',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
).tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedObjects.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(
child: Text('Error: $err'),
),
data: (curatedLocations) => ExploreGrid(
curatedContent: curatedLocations
.map(
(l) => CuratedContent(
label: l.object.capitalizeFirstLetter(),
id: l.id,
),
)
.toList(),
),
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/recently_added.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class RecentlyAddedPage extends HookConsumerWidget {
const RecentlyAddedPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recents = ref.watch(recentlyAddedProvider);
return Scaffold(
appBar: AppBar(
title: const Text('recently_added_page_title').tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: recents.when(
data: (searchResponse) => ImmichAssetGrid(
assets: searchResponse,
),
error: (e, s) => Text(e.toString()),
loading: () => const Center(
child: ImmichLoadingIndicator(),
),
),
);
}
}

View File

@@ -1,17 +1,15 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
import 'package:openapi/api.dart';
// ignore: must_be_immutable
@@ -22,15 +20,21 @@ class SearchPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
AsyncValue<List<CuratedLocationsResponseDto>> curatedLocation =
ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
ref.watch(getCuratedObjectProvider);
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
double imageSize = MediaQuery.of(context).size.width / 3;
TextStyle categoryTitleStyle = const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
);
Color categoryIconColor = isDarkTheme ? Colors.white : Colors.black;
useEffect(
() {
searchFocusNode = FocusNode();
@@ -43,109 +47,72 @@ class SearchPage extends HookConsumerWidget {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: searchTerm,
),
);
}
buildPlaces() {
return curatedLocation.when(
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'),
data: (curatedLocations) {
return curatedLocations.isNotEmpty
? SizedBox(
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: curatedLocation.value?.length,
itemBuilder: ((context, index) {
var locationInfo = curatedLocations[index];
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/thumbnail/${locationInfo.id}';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: locationInfo.city,
onTap: () {
AutoRouter.of(context).push(
SearchResultRoute(searchTerm: locationInfo.city),
);
},
);
}),
return SizedBox(
height: imageSize,
child: curatedLocation.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (locations) => CuratedRow(
content: locations
.map(
(o) => CuratedContent(
id: o.id,
label: o.city,
),
)
: SizedBox(
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: 1,
itemBuilder: ((context, index) {
return ThumbnailWithInfo(
textInfo: '',
onTap: () {},
);
}),
),
);
},
.toList(),
imageSize: imageSize,
onTap: (content, index) {
AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: 'm:${content.label}',
),
);
},
),
),
);
}
buildThings() {
return curatedObjects.when(
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'),
data: (objects) {
return objects.isNotEmpty
? SizedBox(
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: curatedObjects.value?.length,
itemBuilder: ((context, index) {
var curatedObjectInfo = objects[index];
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/thumbnail/${curatedObjectInfo.id}';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: curatedObjectInfo.object,
onTap: () {
AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: curatedObjectInfo.object
.capitalizeFirstLetter(),
),
);
},
);
}),
return SizedBox(
height: imageSize,
child: curatedObjects.when(
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => SizedBox(
height: imageSize,
child: Center(child: Text('Error: $err')),
),
data: (objects) => CuratedRow(
content: objects
.map(
(o) => CuratedContent(
id: o.id,
label: o.object,
),
)
: SizedBox(
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: 1,
itemBuilder: ((context, index) {
return ThumbnailWithInfo(
textInfo: '',
noImageIcon: Icons.signal_cellular_no_sim_sharp,
onTap: () {},
);
}),
),
);
},
.toList(),
imageSize: imageSize,
onTap: (content, index) {
AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: 'm:${content.label}',
),
);
},
),
),
);
}
@@ -162,24 +129,161 @@ class SearchPage extends HookConsumerWidget {
child: Stack(
children: [
ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: const Text(
"search_page_places",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"search_page_places",
style: Theme.of(context).textTheme.titleSmall,
).tr(),
TextButton(
child: Text(
'search_page_view_all_button',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
).tr(),
onPressed: () => AutoRouter.of(context).push(
const CuratedLocationRoute(),
),
),
],
),
),
buildPlaces(),
Padding(
padding: const EdgeInsets.all(16.0),
child: const Text(
"search_page_things",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
padding: const EdgeInsets.only(
top: 24.0,
bottom: 4.0,
left: 16.0,
right: 16.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"search_page_things",
style: Theme.of(context).textTheme.titleSmall,
).tr(),
TextButton(
child: Text(
'search_page_view_all_button',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
).tr(),
onPressed: () => AutoRouter.of(context).push(
const CuratedObjectRoute(),
),
),
],
),
),
buildThings(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'search_page_your_activity',
style: Theme.of(context).textTheme.titleSmall,
).tr(),
),
buildThings()
ListTile(
leading: Icon(
Icons.star_outline,
color: categoryIconColor,
),
title:
Text('search_page_favorites', style: categoryTitleStyle)
.tr(),
onTap: () => AutoRouter.of(context).push(
const FavoritesRoute(),
),
),
const CategoryDivider(),
ListTile(
leading: Icon(
Icons.schedule_outlined,
color: categoryIconColor,
),
title: Text(
'search_page_recently_added',
style: categoryTitleStyle,
).tr(),
onTap: () => AutoRouter.of(context).push(
const RecentlyAddedRoute(),
),
),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'search_page_categories',
style: Theme.of(context).textTheme.titleSmall,
).tr(),
),
ListTile(
title: Text('Screenshots', style: categoryTitleStyle).tr(),
leading: Icon(
Icons.screenshot,
color: categoryIconColor,
),
onTap: () => AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: 'screenshots',
),
),
),
const CategoryDivider(),
ListTile(
title: Text('search_page_selfies', style: categoryTitleStyle)
.tr(),
leading: Icon(
Icons.photo_camera_front_outlined,
color: categoryIconColor,
),
onTap: () => AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: 'selfies',
),
),
),
const CategoryDivider(),
ListTile(
title: Text('search_page_videos', style: categoryTitleStyle)
.tr(),
leading: Icon(
Icons.play_circle_outline,
color: categoryIconColor,
),
onTap: () => AutoRouter.of(context).push(
const AllVideosRoute(),
),
),
const CategoryDivider(),
ListTile(
title: Text(
'search_page_motion_photos',
style: categoryTitleStyle,
).tr(),
leading: Icon(
Icons.motion_photos_on_outlined,
color: categoryIconColor,
),
onTap: () => AutoRouter.of(context).push(
const AllMotionPhotosRoute(),
),
),
],
),
if (isSearchEnabled)
@@ -190,3 +294,20 @@ class SearchPage extends HookConsumerWidget {
);
}
}
class CategoryDivider extends StatelessWidget {
const CategoryDivider({super.key});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(
left: 72,
right: 16,
),
child: Divider(
height: 0,
),
);
}
}

View File

@@ -6,14 +6,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_result_grid.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SearchType {
SearchType({required this.isClip, required this.searchTerm});
final bool isClip;
final String searchTerm;
}
SearchType _getSearchType(String searchTerm) {
if (searchTerm.startsWith('m:')) {
return SearchType(isClip: false, searchTerm: searchTerm.substring(2));
} else {
return SearchType(isClip: true, searchTerm: searchTerm);
}
}
class SearchResultPage extends HookConsumerWidget {
const SearchResultPage({Key? key, required this.searchTerm})
: super(key: key);
const SearchResultPage({
Key? key,
required this.searchTerm,
}) : super(key: key);
final String searchTerm;
@@ -22,6 +38,8 @@ class SearchResultPage extends HookConsumerWidget {
final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final isDisplayDateGroup = useState(true);
FocusNode? searchFocusNode;
@@ -29,9 +47,16 @@ class SearchResultPage extends HookConsumerWidget {
() {
searchFocusNode = FocusNode();
var searchType = _getSearchType(searchTerm);
searchType.isClip
? isDisplayDateGroup.value = false
: isDisplayDateGroup.value = true;
Future.delayed(
Duration.zero,
() => ref.read(searchResultPageProvider.notifier).search(searchTerm),
() => ref
.read(searchResultPageProvider.notifier)
.search(searchType.searchTerm, clipEnable: searchType.isClip),
);
return () => searchFocusNode?.dispose();
},
@@ -43,7 +68,15 @@ class SearchResultPage extends HookConsumerWidget {
searchFocusNode?.unfocus();
isNewSearch.value = false;
currentSearchTerm.value = newSearchTerm;
ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
var searchType = _getSearchType(newSearchTerm);
searchType.isClip
? isDisplayDateGroup.value = false
: isDisplayDateGroup.value = true;
ref
.watch(searchResultPageProvider.notifier)
.search(searchType.searchTerm, clipEnable: searchType.isClip);
}
buildTextField() {
@@ -76,6 +109,12 @@ class SearchResultPage extends HookConsumerWidget {
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
hintStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
color:
isDarkTheme ? Colors.grey[500] : Colors.black.withOpacity(0.5),
),
),
);
}
@@ -110,14 +149,8 @@ class SearchResultPage extends HookConsumerWidget {
buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageProvider);
var searchResultRenderList = ref.watch(searchRenderListProvider);
var allSearchAssets = ref.watch(searchResultPageProvider).searchResult;
var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
final showStorageIndicator =
settings.getSetting(AppSettingsEnum.storageIndicator);
if (searchResultPageState.isError) {
return Padding(
padding: const EdgeInsets.all(12),
@@ -130,22 +163,15 @@ class SearchResultPage extends HookConsumerWidget {
}
if (searchResultPageState.isSuccess) {
return searchResultRenderList.when(
data: (result) {
return ImmichAssetGrid(
allAssets: allSearchAssets,
renderList: result,
assetsPerRow: assetsPerRow,
showStorageIndicator: showStorageIndicator,
);
},
error: (err, stack) {
return Text("$err");
},
loading: () {
return const CircularProgressIndicator();
},
);
if (isDisplayDateGroup.value) {
return ImmichAssetGrid(
assets: allSearchAssets,
);
} else {
return SearchResultGrid(
assets: allSearchAssets,
);
}
}
return const SizedBox();

View File

@@ -1,59 +1,63 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/store.dart';
enum AppSettingsEnum<T> {
loadPreview<bool>("loadPreview", true),
loadOriginal<bool>("loadOriginal", false),
themeMode<String>("themeMode", "system"), // "light","dark","system"
tilesPerRow<int>("tilesPerRow", 4),
dynamicLayout<bool>("dynamicLayout", false),
groupAssetsBy<int>("groupBy", 0),
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
themeMode<String>(
StoreKey.themeMode,
"themeMode",
"system",
), // "light","dark","system"
tilesPerRow<int>(StoreKey.tilesPerRow, "tilesPerRow", 4),
dynamicLayout<bool>(StoreKey.dynamicLayout, "dynamicLayout", false),
groupAssetsBy<int>(StoreKey.groupAssetsBy, "groupBy", 0),
uploadErrorNotificationGracePeriod<int>(
StoreKey.uploadErrorNotificationGracePeriod,
"uploadErrorNotificationGracePeriod",
2,
),
backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
storageIndicator<bool>("storageIndicator", true),
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
imageCacheSize<int>("imageCacheSize", 350),
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false),
selectedAlbumSortOrder<int>("selectedAlbumSortOrder", 0);
backgroundBackupTotalProgress<bool>(
StoreKey.backgroundBackupTotalProgress,
"backgroundBackupTotalProgress",
true,
),
backgroundBackupSingleProgress<bool>(
StoreKey.backgroundBackupSingleProgress,
"backgroundBackupSingleProgress",
false,
),
storageIndicator<bool>(StoreKey.storageIndicator, "storageIndicator", true),
thumbnailCacheSize<int>(
StoreKey.thumbnailCacheSize,
"thumbnailCacheSize",
10000,
),
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
albumThumbnailCacheSize<int>(
StoreKey.albumThumbnailCacheSize,
"albumThumbnailCacheSize",
200,
),
selectedAlbumSortOrder<int>(
StoreKey.selectedAlbumSortOrder,
"selectedAlbumSortOrder",
0,
),
;
const AppSettingsEnum(this.hiveKey, this.defaultValue);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
final StoreKey<T> storeKey;
final String hiveKey;
final T defaultValue;
}
class AppSettingsService {
late final Box hiveBox;
AppSettingsService() {
hiveBox = Hive.box(userSettingInfoBox);
T getSetting<T>(AppSettingsEnum<T> setting) {
return Store.get(setting.storeKey, setting.defaultValue);
}
T getSetting<T>(AppSettingsEnum<T> settingType) {
if (!hiveBox.containsKey(settingType.hiveKey)) {
return _setDefault(settingType);
}
var result = hiveBox.get(settingType.hiveKey);
if (result is! T) {
return _setDefault(settingType);
}
return result;
}
setSetting<T>(AppSettingsEnum<T> settingType, T value) {
hiveBox.put(settingType.hiveKey, value);
}
T _setDefault<T>(AppSettingsEnum<T> settingType) {
hiveBox.put(settingType.hiveKey, settingType.defaultValue);
return settingType.defaultValue;
void setSetting<T>(AppSettingsEnum<T> setting, T value) {
Store.put(setting.storeKey, value);
}
}

View File

@@ -13,7 +13,6 @@ class AuthGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) async {
try {
var res = await _apiService.authenticationApi.validateAccessToken();
if (res != null && res.authStatus) {
resolver.next(true);
} else {

View File

@@ -21,6 +21,11 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
import 'package:immich_mobile/modules/search/views/curated_object_page.dart';
import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
@@ -64,8 +69,13 @@ part 'router.gr.dart';
AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CuratedLocationPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AllVideosPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AllMotionPhotosPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: RecentlyAddedPage, guards: [AuthGuard, DuplicateGuard],),
CustomRoute<AssetSelectionPageResult?>(
page: AssetSelectionPage,
guards: [AuthGuard, DuplicateGuard],

View File

@@ -102,6 +102,18 @@ class _$AppRouter extends RootStackRouter {
),
);
},
CuratedLocationRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const CuratedLocationPage(),
);
},
CuratedObjectRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const CuratedObjectPage(),
);
},
CreateAlbumRoute.name: (routeData) {
final args = routeData.argsAs<CreateAlbumRouteArgs>();
return MaterialPageX<dynamic>(
@@ -119,6 +131,24 @@ class _$AppRouter extends RootStackRouter {
child: const FavoritesPage(),
);
},
AllVideosRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const AllVideosPage(),
);
},
AllMotionPhotosRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const AllMotionPhotosPage(),
);
},
RecentlyAddedRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const RecentlyAddedPage(),
);
},
AssetSelectionRoute.name: (routeData) {
return CustomPage<AssetSelectionPageResult?>(
routeData: routeData,
@@ -331,6 +361,22 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
CuratedLocationRoute.name,
path: '/curated-location-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
CuratedObjectRoute.name,
path: '/curated-object-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
CreateAlbumRoute.name,
path: '/create-album-page',
@@ -347,6 +393,30 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
AllVideosRoute.name,
path: '/all-videos-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
AllMotionPhotosRoute.name,
path: '/all-motion-photos-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
RecentlyAddedRoute.name,
path: '/recently-added-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
AssetSelectionRoute.name,
path: '/asset-selection-page',
@@ -618,6 +688,30 @@ class SearchResultRouteArgs {
}
}
/// generated route for
/// [CuratedLocationPage]
class CuratedLocationRoute extends PageRouteInfo<void> {
const CuratedLocationRoute()
: super(
CuratedLocationRoute.name,
path: '/curated-location-page',
);
static const String name = 'CuratedLocationRoute';
}
/// generated route for
/// [CuratedObjectPage]
class CuratedObjectRoute extends PageRouteInfo<void> {
const CuratedObjectRoute()
: super(
CuratedObjectRoute.name,
path: '/curated-object-page',
);
static const String name = 'CuratedObjectRoute';
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
@@ -669,6 +763,42 @@ class FavoritesRoute extends PageRouteInfo<void> {
static const String name = 'FavoritesRoute';
}
/// generated route for
/// [AllVideosPage]
class AllVideosRoute extends PageRouteInfo<void> {
const AllVideosRoute()
: super(
AllVideosRoute.name,
path: '/all-videos-page',
);
static const String name = 'AllVideosRoute';
}
/// generated route for
/// [AllMotionPhotosPage]
class AllMotionPhotosRoute extends PageRouteInfo<void> {
const AllMotionPhotosRoute()
: super(
AllMotionPhotosRoute.name,
path: '/all-motion-photos-page',
);
static const String name = 'AllMotionPhotosRoute';
}
/// generated route for
/// [RecentlyAddedPage]
class RecentlyAddedRoute extends PageRouteInfo<void> {
const RecentlyAddedRoute()
: super(
RecentlyAddedRoute.name,
path: '/recently-added-page',
);
static const String name = 'RecentlyAddedRoute';
}
/// generated route for
/// [AssetSelectionPage]
class AssetSelectionRoute extends PageRouteInfo<void> {

View File

@@ -1,6 +1,5 @@
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
@@ -16,11 +15,11 @@ class Asset {
Asset.remote(AssetResponseDto remote)
: remoteId = remote.id,
isLocal = false,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
// use -1 as fallback duration (to not mix it up with non-video assets correctly having duration=0)
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? -1,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
updatedAt = DateTime.parse(remote.updatedAt),
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(),
fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
@@ -36,15 +35,16 @@ class Asset {
: localId = local.id,
isLocal = true,
durationInSeconds = local.duration,
type = AssetType.values[local.typeInt],
height = local.height,
width = local.width,
fileName = local.title!,
deviceId = Store.get(StoreKey.deviceIdHash),
ownerId = Store.get<User>(StoreKey.currentUser)!.isarId,
fileModifiedAt = local.modifiedDateTime.toUtc(),
updatedAt = local.modifiedDateTime.toUtc(),
ownerId = Store.get(StoreKey.currentUser).isarId,
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
fileCreatedAt = local.createDateTime.toUtc() {
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
@@ -62,6 +62,7 @@ class Asset {
required this.fileModifiedAt,
required this.updatedAt,
required this.durationInSeconds,
required this.type,
this.width,
this.height,
required this.fileName,
@@ -78,10 +79,10 @@ class Asset {
AssetEntity? get local {
if (isLocal && _local == null) {
_local = AssetEntity(
id: localId.toString(),
id: localId,
typeInt: isImage ? 1 : 2,
width: width!,
height: height!,
width: width ?? 0,
height: height ?? 0,
duration: durationInSeconds,
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
@@ -97,7 +98,7 @@ class Asset {
String? remoteId;
@Index(
unique: true,
unique: false,
replace: false,
type: IndexType.hash,
composite: [CompositeIndex('deviceId')],
@@ -116,6 +117,9 @@ class Asset {
int durationInSeconds;
@Enumerated(EnumType.ordinal)
AssetType type;
short? width;
short? height;
@@ -141,7 +145,7 @@ class Asset {
bool get isRemote => remoteId != null;
@ignore
bool get isImage => durationInSeconds == 0;
bool get isImage => type == AssetType.image;
@ignore
Duration get duration => Duration(seconds: durationInSeconds);
@@ -149,12 +153,43 @@ class Asset {
@override
bool operator ==(other) {
if (other is! Asset) return false;
return id == other.id;
return id == other.id &&
remoteId == other.remoteId &&
localId == other.localId &&
deviceId == other.deviceId &&
ownerId == other.ownerId &&
fileCreatedAt == other.fileCreatedAt &&
fileModifiedAt == other.fileModifiedAt &&
updatedAt == other.updatedAt &&
durationInSeconds == other.durationInSeconds &&
type == other.type &&
width == other.width &&
height == other.height &&
fileName == other.fileName &&
livePhotoVideoId == other.livePhotoVideoId &&
isFavorite == other.isFavorite &&
isLocal == other.isLocal;
}
@override
@ignore
int get hashCode => id.hashCode;
int get hashCode =>
id.hashCode ^
remoteId.hashCode ^
localId.hashCode ^
deviceId.hashCode ^
ownerId.hashCode ^
fileCreatedAt.hashCode ^
fileModifiedAt.hashCode ^
updatedAt.hashCode ^
durationInSeconds.hashCode ^
type.hashCode ^
width.hashCode ^
height.hashCode ^
fileName.hashCode ^
livePhotoVideoId.hashCode ^
isFavorite.hashCode ^
isLocal.hashCode;
bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields;
@@ -193,9 +228,24 @@ class Asset {
}
}
static int compareByDeviceIdLocalId(Asset a, Asset b) {
final int order = a.deviceId.compareTo(b.deviceId);
return order == 0 ? a.localId.compareTo(b.localId) : order;
/// compares assets by [ownerId], [deviceId], [localId]
static int compareByOwnerDeviceLocalId(Asset a, Asset b) {
final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
if (ownerIdOrder != 0) {
return ownerIdOrder;
}
final int deviceIdOrder = a.deviceId.compareTo(b.deviceId);
if (deviceIdOrder != 0) {
return deviceIdOrder;
}
final int localIdOrder = a.localId.compareTo(b.localId);
return localIdOrder;
}
/// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt]
static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) {
final int order = compareByOwnerDeviceLocalId(a, b);
return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt);
}
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
@@ -204,6 +254,30 @@ class Asset {
a.localId.compareTo(b.localId);
}
enum AssetType {
// do not change this order!
other,
image,
video,
audio,
}
extension AssetTypeEnumHelper on AssetTypeEnum {
AssetType toAssetType() {
switch (this) {
case AssetTypeEnum.IMAGE:
return AssetType.image;
case AssetTypeEnum.VIDEO:
return AssetType.video;
case AssetTypeEnum.AUDIO:
return AssetType.audio;
case AssetTypeEnum.OTHER:
return AssetType.other;
}
throw Exception();
}
}
extension AssetsHelper on IsarCollection<Asset> {
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();

View File

@@ -77,13 +77,19 @@ const AssetSchema = CollectionSchema(
name: r'remoteId',
type: IsarType.string,
),
r'updatedAt': PropertySchema(
r'type': PropertySchema(
id: 12,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 13,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 13,
id: 14,
name: r'width',
type: IsarType.int,
)
@@ -110,7 +116,7 @@ const AssetSchema = CollectionSchema(
r'localId_deviceId': IndexSchema(
id: 7649417350086526165,
name: r'localId_deviceId',
unique: true,
unique: false,
replace: false,
properties: [
IndexPropertySchema(
@@ -175,8 +181,9 @@ void _assetSerialize(
writer.writeString(offsets[9], object.localId);
writer.writeLong(offsets[10], object.ownerId);
writer.writeString(offsets[11], object.remoteId);
writer.writeDateTime(offsets[12], object.updatedAt);
writer.writeInt(offsets[13], object.width);
writer.writeByte(offsets[12], object.type.index);
writer.writeDateTime(offsets[13], object.updatedAt);
writer.writeInt(offsets[14], object.width);
}
Asset _assetDeserialize(
@@ -198,8 +205,10 @@ Asset _assetDeserialize(
localId: reader.readString(offsets[9]),
ownerId: reader.readLong(offsets[10]),
remoteId: reader.readStringOrNull(offsets[11]),
updatedAt: reader.readDateTime(offsets[12]),
width: reader.readIntOrNull(offsets[13]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[13]),
width: reader.readIntOrNull(offsets[14]),
);
object.id = id;
return object;
@@ -237,14 +246,30 @@ P _assetDeserializeProp<P>(
case 11:
return (reader.readStringOrNull(offset)) as P;
case 12:
return (reader.readDateTime(offset)) as P;
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 13:
return (reader.readDateTime(offset)) as P;
case 14:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
const _AssettypeEnumValueMap = {
'other': 0,
'image': 1,
'video': 2,
'audio': 3,
};
const _AssettypeValueEnumMap = {
0: AssetType.other,
1: AssetType.image,
2: AssetType.video,
3: AssetType.audio,
};
Id _assetGetId(Asset object) {
return object.id;
}
@@ -257,94 +282,6 @@ void _assetAttach(IsarCollection<dynamic> col, Id id, Asset object) {
object.id = id;
}
extension AssetByIndex on IsarCollection<Asset> {
Future<Asset?> getByLocalIdDeviceId(String localId, int deviceId) {
return getByIndex(r'localId_deviceId', [localId, deviceId]);
}
Asset? getByLocalIdDeviceIdSync(String localId, int deviceId) {
return getByIndexSync(r'localId_deviceId', [localId, deviceId]);
}
Future<bool> deleteByLocalIdDeviceId(String localId, int deviceId) {
return deleteByIndex(r'localId_deviceId', [localId, deviceId]);
}
bool deleteByLocalIdDeviceIdSync(String localId, int deviceId) {
return deleteByIndexSync(r'localId_deviceId', [localId, deviceId]);
}
Future<List<Asset?>> getAllByLocalIdDeviceId(
List<String> localIdValues, List<int> deviceIdValues) {
final len = localIdValues.length;
assert(deviceIdValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([localIdValues[i], deviceIdValues[i]]);
}
return getAllByIndex(r'localId_deviceId', values);
}
List<Asset?> getAllByLocalIdDeviceIdSync(
List<String> localIdValues, List<int> deviceIdValues) {
final len = localIdValues.length;
assert(deviceIdValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([localIdValues[i], deviceIdValues[i]]);
}
return getAllByIndexSync(r'localId_deviceId', values);
}
Future<int> deleteAllByLocalIdDeviceId(
List<String> localIdValues, List<int> deviceIdValues) {
final len = localIdValues.length;
assert(deviceIdValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([localIdValues[i], deviceIdValues[i]]);
}
return deleteAllByIndex(r'localId_deviceId', values);
}
int deleteAllByLocalIdDeviceIdSync(
List<String> localIdValues, List<int> deviceIdValues) {
final len = localIdValues.length;
assert(deviceIdValues.length == len,
'All index values must have the same length');
final values = <List<dynamic>>[];
for (var i = 0; i < len; i++) {
values.add([localIdValues[i], deviceIdValues[i]]);
}
return deleteAllByIndexSync(r'localId_deviceId', values);
}
Future<Id> putByLocalIdDeviceId(Asset object) {
return putByIndex(r'localId_deviceId', object);
}
Id putByLocalIdDeviceIdSync(Asset object, {bool saveLinks = true}) {
return putByIndexSync(r'localId_deviceId', object, saveLinks: saveLinks);
}
Future<List<Id>> putAllByLocalIdDeviceId(List<Asset> objects) {
return putAllByIndex(r'localId_deviceId', objects);
}
List<Id> putAllByLocalIdDeviceIdSync(List<Asset> objects,
{bool saveLinks = true}) {
return putAllByIndexSync(r'localId_deviceId', objects,
saveLinks: saveLinks);
}
}
extension AssetQueryWhereSort on QueryBuilder<Asset, Asset, QWhere> {
QueryBuilder<Asset, Asset, QAfterWhere> anyId() {
return QueryBuilder.apply(this, (query) {
@@ -1582,6 +1519,59 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
AssetType value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'type',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeGreaterThan(
AssetType value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'type',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeLessThan(
AssetType value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'type',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeBetween(
AssetType lower,
AssetType upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'type',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> updatedAtEqualTo(
DateTime value) {
return QueryBuilder.apply(this, (query) {
@@ -1853,6 +1843,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByTypeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByUpdatedAt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'updatedAt', Sort.asc);
@@ -2035,6 +2037,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByTypeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByUpdatedAt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'updatedAt', Sort.asc);
@@ -2138,6 +2152,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type');
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByUpdatedAt() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'updatedAt');
@@ -2230,6 +2250,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type');
});
}
QueryBuilder<Asset, DateTime, QQueryOperations> updatedAtProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'updatedAt');

View File

@@ -0,0 +1,48 @@
// ignore_for_file: constant_identifier_names
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
part 'logger_message.model.g.dart';
@Collection(inheritance: false)
class LoggerMessage {
Id id = Isar.autoIncrement;
String message;
@Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO;
DateTime createdAt;
String? context1;
String? context2;
LoggerMessage({
required this.message,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
}
/// Log levels according to dart logging [Level]
enum LogLevel {
ALL,
FINEST,
FINER,
FINE,
CONFIG,
INFO,
WARNING,
SEVERE,
SHOUT,
OFF,
}
extension LevelExtension on Level {
LogLevel toLogLevel() => LogLevel.values[Level.LEVELS.indexOf(this)];
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
part 'store.g.dart';
@@ -26,12 +25,21 @@ class Store {
return _db.writeTxn(() => _db.storeValues.clear());
}
/// Returns the stored value for the given key, or the default value if null
static T? get<T>(StoreKey key, [T? defaultValue]) =>
_cache[key.id] ?? defaultValue;
/// Returns the stored value for the given key or if null the [defaultValue]
/// Throws a [StoreKeyNotFoundException] if both are null
static T get<T>(StoreKey<T> key, [T? defaultValue]) {
final value = _cache[key.id] ?? defaultValue;
if (value == null) {
throw StoreKeyNotFoundException(key);
}
return value;
}
/// Returns the stored value for the given key (possibly null)
static T? tryGet<T>(StoreKey<T> key) => _cache[key.id];
/// Stores the value synchronously in the cache and asynchronously in the DB
static Future<void> put<T>(StoreKey key, T value) {
static Future<void> put<T>(StoreKey<T> key, T value) {
_cache[key.id] = value;
return _db.writeTxn(
() async => _db.storeValues.put(await StoreValue._of(value, key)),
@@ -39,7 +47,7 @@ class Store {
}
/// Removes the value synchronously from the cache and asynchronously from the DB
static Future<void> delete(StoreKey key) {
static Future<void> delete<T>(StoreKey<T> key) {
_cache[key.id] = null;
return _db.writeTxn(() => _db.storeValues.delete(key.id));
}
@@ -58,7 +66,8 @@ class Store {
static void _onChangeListener(List<StoreValue>? data) {
if (data != null) {
for (StoreValue value in data) {
_cache[value.id] = value._extract(StoreKey.values[value.id]);
_cache[value.id] =
value._extract(StoreKey.values.firstWhere((e) => e.id == value.id));
}
}
}
@@ -72,76 +81,114 @@ class StoreValue {
int? intValue;
String? strValue;
dynamic _extract(StoreKey key) {
T? _extract<T>(StoreKey<T> key) {
switch (key.type) {
case int:
return key.fromDb == null
? intValue
: key.fromDb!.call(Store._db, intValue!);
return intValue as T?;
case bool:
return intValue == null ? null : intValue! == 1;
return intValue == null ? null : (intValue! == 1) as T;
case DateTime:
return intValue == null
? null
: DateTime.fromMicrosecondsSinceEpoch(intValue!);
: DateTime.fromMicrosecondsSinceEpoch(intValue!) as T;
case String:
return key.fromJson != null
? key.fromJson!.call(json.decode(strValue!))
: strValue;
return strValue as T?;
default:
if (key.fromDb != null) {
return key.fromDb!.call(Store._db, intValue!);
}
}
throw TypeError();
}
static Future<StoreValue> _of(dynamic value, StoreKey key) async {
static Future<StoreValue> _of<T>(T? value, StoreKey<T> key) async {
int? i;
String? s;
switch (key.type) {
case int:
i = (key.toDb == null ? value : await key.toDb!.call(Store._db, value));
i = value as int?;
break;
case bool:
i = value == null ? null : (value ? 1 : 0);
i = value == null ? null : (value == true ? 1 : 0);
break;
case DateTime:
i = value == null ? null : (value as DateTime).microsecondsSinceEpoch;
break;
case String:
s = key.fromJson == null ? value : json.encode(value.toJson());
s = value as String?;
break;
default:
if (key.toDb != null) {
i = await key.toDb!.call(Store._db, value);
break;
}
throw TypeError();
}
return StoreValue(key.id, intValue: i, strValue: s);
}
}
class StoreKeyNotFoundException implements Exception {
final StoreKey key;
StoreKeyNotFoundException(this.key);
@override
String toString() => "Key '${key.name}' not found in Store";
}
/// Key for each possible value in the `Store`.
/// Defines the data type (int, String, JSON) for each value
enum StoreKey {
userRemoteId(0),
assetETag(1),
currentUser(2, type: int, fromDb: _getUser, toDb: _toUser),
deviceIdHash(3, type: int),
deviceId(4),
backupFailedSince(5, type: DateTime),
backupRequireWifi(6, type: bool),
backupRequireCharging(7, type: bool),
backupTriggerDelay(8, type: int);
/// Defines the data type for each value
enum StoreKey<T> {
version<int>(0, type: int),
assetETag<String>(1, type: String),
currentUser<User>(2, type: User, fromDb: _getUser, toDb: _toUser),
deviceIdHash<int>(3, type: int),
deviceId<String>(4, type: String),
backupFailedSince<DateTime>(5, type: DateTime),
backupRequireWifi<bool>(6, type: bool),
backupRequireCharging<bool>(7, type: bool),
backupTriggerDelay<int>(8, type: int),
githubReleaseInfo<String>(9, type: String),
serverUrl<String>(10, type: String),
accessToken<String>(11, type: String),
serverEndpoint<String>(12, type: String),
autoBackup<bool>(13, type: bool),
// user settings from [AppSettingsEnum] below:
loadPreview<bool>(100, type: bool),
loadOriginal<bool>(101, type: bool),
themeMode<String>(102, type: String),
tilesPerRow<int>(103, type: int),
dynamicLayout<bool>(104, type: bool),
groupAssetsBy<int>(105, type: int),
uploadErrorNotificationGracePeriod<int>(106, type: int),
backgroundBackupTotalProgress<bool>(107, type: bool),
backgroundBackupSingleProgress<bool>(108, type: bool),
storageIndicator<bool>(109, type: bool),
thumbnailCacheSize<int>(110, type: int),
imageCacheSize<int>(111, type: int),
albumThumbnailCacheSize<int>(112, type: int),
selectedAlbumSortOrder<int>(113, type: int),
;
const StoreKey(
this.id, {
this.type = String,
required this.type,
this.fromDb,
this.toDb,
// ignore: unused_element
this.fromJson,
});
final int id;
final Type type;
final dynamic Function(Isar, int)? fromDb;
final Future<int> Function(Isar, dynamic)? toDb;
final Function(dynamic)? fromJson;
final T? Function<T>(Isar, int)? fromDb;
final Future<int> Function<T>(Isar, T)? toDb;
}
User? _getUser(Isar db, int i) => db.users.getSync(i);
Future<int> _toUser(Isar db, dynamic u) {
User user = (u as User);
return db.users.put(user);
T? _getUser<T>(Isar db, int i) {
final User? u = db.users.getSync(i);
return u as T?;
}
Future<int> _toUser<T>(Isar db, T u) {
if (u is User) {
return db.users.put(u);
}
throw TypeError();
}

View File

@@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -12,6 +10,9 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:intl/intl.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
@@ -53,15 +54,18 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService;
final AppSettingsService _settingsService;
final AlbumService _albumService;
final SyncService _syncService;
final Isar _db;
final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
final AsyncMutex _stateUpdateLock = AsyncMutex();
AssetNotifier(
this._assetService,
this._settingsService,
this._albumService,
this._syncService,
this._db,
) : super(AssetsState.fromAssetList([]));
@@ -81,24 +85,30 @@ class AssetNotifier extends StateNotifier<AssetsState> {
await _updateAssetsState(state.allAssets);
}
getAllAsset() async {
Future<void> getAllAsset({bool clear = false}) async {
if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working
return;
}
final stopwatch = Stopwatch();
final stopwatch = Stopwatch()..start();
try {
_getAllAssetInProgress = true;
final User me = Store.get(StoreKey.currentUser);
final int cachedCount =
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
stopwatch.start();
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _updateAssetsState(await _getUserAssets(me.isarId));
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
if (clear) {
await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db");
} else if (_stateUpdateLock.enqueued <= 1) {
final int cachedCount =
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
}
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
@@ -112,10 +122,14 @@ class AssetNotifier extends StateNotifier<AssetsState> {
return;
}
stopwatch.reset();
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
if (_stateUpdateLock.enqueued <= 1) {
_stateUpdateLock.run(() async {
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
});
}
} finally {
_getAllAssetInProgress = false;
@@ -130,47 +144,18 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<void> clearAllAsset() {
state = AssetsState.empty();
return _db.writeTxn(() async {
await _db.assets.clear();
await _db.exifInfos.clear();
await _db.albums.clear();
});
return clearAssetsAndAlbums(_db);
}
Future<void> onNewAssetUploaded(Asset newAsset) async {
final int i = state.allAssets.indexWhere(
(a) =>
a.isRemote ||
(a.localId == newAsset.localId && a.deviceId == newAsset.deviceId),
);
if (i == -1 ||
state.allAssets[i].localId != newAsset.localId ||
state.allAssets[i].deviceId != newAsset.deviceId) {
await _updateAssetsState([...state.allAssets, newAsset]);
} else {
// unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset
final Asset? inDb = await _db.assets
.where()
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
.findFirst();
if (inDb != null) {
newAsset.id = inDb.id;
newAsset.isLocal = inDb.isLocal;
}
// order is important to keep all local-only assets at the beginning!
await _updateAssetsState([
...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1),
newAsset,
]);
}
try {
await _db.writeTxn(() => newAsset.put(_db));
} on IsarError catch (e) {
debugPrint(e.toString());
final bool ok = await _syncService.syncNewAssetToDb(newAsset);
if (ok && _stateUpdateLock.enqueued <= 1) {
// run this sequentially if there is at most 1 other task waiting
await _stateUpdateLock.run(() async {
final userId = Store.get(StoreKey.currentUser).isarId;
final assets = await _getUserAssets(userId);
await _updateAssetsState(assets);
});
}
}
@@ -253,6 +238,7 @@ final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
ref.watch(assetServiceProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
);
});

View File

@@ -1,10 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:logging/logging.dart';
@@ -13,10 +12,10 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
final log = Logger('ReleaseInfoNotifier');
void checkGithubReleaseInfo() async {
final Client client = Client();
var box = Hive.box(hiveGithubReleaseInfoBox);
try {
String? localReleaseVersion = box.get(githubReleaseInfoKey);
final String? localReleaseVersion =
Store.tryGet(StoreKey.githubReleaseInfo);
final res = await client.get(
Uri.parse(
"https://api.github.com/repos/immich-app/immich/releases/latest",
@@ -48,9 +47,7 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
}
void acknowledgeNewVersion() {
var box = Hive.box(hiveGithubReleaseInfoBox);
box.put(githubReleaseInfoKey, state);
Store.put(StoreKey.githubReleaseInfo, state);
VersionAnnouncementOverlayController.appLoader.hide();
}
}

View File

@@ -1,11 +1,10 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -58,9 +57,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
var authenticationState = ref.read(authenticationProvider);
if (authenticationState.isAuthenticated) {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
final accessToken = Store.get(StoreKey.accessToken);
try {
var endpoint = Uri.parse(Hive.box(userInfoBox).get(serverEndpointKey));
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
debugPrint("Attempting to connect to websocket");
// Configure socket transports must be specified

View File

@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:openapi/api.dart';
import 'package:http/http.dart';
@@ -15,17 +14,13 @@ class ApiService {
late OAuthApi oAuthApi;
late AlbumApi albumApi;
late AssetApi assetApi;
late SearchApi searchApi;
late ServerInfoApi serverInfoApi;
late DeviceInfoApi deviceInfoApi;
ApiService() {
if (Hive.isBoxOpen(userInfoBox)) {
final endpoint = Hive.box(userInfoBox).get(serverEndpointKey) as String?;
if (endpoint != null && endpoint.isNotEmpty) {
setEndpoint(endpoint);
}
} else {
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
if (endpoint != null && endpoint.isNotEmpty) {
setEndpoint(endpoint);
}
}
String? _authToken;
@@ -41,7 +36,7 @@ class ApiService {
albumApi = AlbumApi(_apiClient);
assetApi = AssetApi(_apiClient);
serverInfoApi = ServerInfoApi(_apiClient);
deviceInfoApi = DeviceInfoApi(_apiClient);
searchApi = SearchApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {
@@ -49,7 +44,7 @@ class ApiService {
setEndpoint(endpoint);
// Save in hivebox for next startup
Hive.box(userInfoBox).put(serverEndpointKey, endpoint);
Store.put(StoreKey.serverEndpoint, endpoint);
return endpoint;
}

View File

@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@@ -44,16 +43,13 @@ class AssetService {
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get<User>(StoreKey.currentUser)!.isarId)
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.count();
final List<AssetResponseDto>? dtos =
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0);
if (dtos == null) {
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms");
return false;
}
final bool changes = await _syncService
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
final bool changes = await _syncService.syncRemoteAssetsToDb(
() async => (await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0))
?.map(Asset.remote)
.toList(),
);
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes;
}
@@ -63,7 +59,7 @@ class AssetService {
required bool hasCache,
}) async {
try {
final etag = hasCache ? Store.get(StoreKey.assetETag) : null;
final etag = hasCache ? Store.tryGet(StoreKey.assetETag) : null;
final Pair<List<AssetResponseDto>, String?>? remote =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
if (remote == null) {

View File

@@ -1,15 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to a Hive box and onto console, using `debugPrint` method.
/// The logs are written to the database and onto console, using `debugPrint` method.
///
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
/// in the class.
@@ -17,48 +17,67 @@ import 'package:share_plus/share_plus.dart';
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file.
class ImmichLogger {
static final ImmichLogger _instance = ImmichLogger._internal();
final maxLogEntries = 200;
final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
final Isar _db = Isar.getInstance()!;
List<LoggerMessage> _msgBuffer = [];
Timer? _timer;
List<ImmichLoggerMessage> get messages =>
_box.values.toList().reversed.toList();
factory ImmichLogger() => _instance;
ImmichLogger() {
ImmichLogger._internal() {
_removeOverflowMessages();
}
init() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen(_writeLogToHiveBox);
Logger.root.onRecord.listen(_writeLogToDatabase);
}
_removeOverflowMessages() {
if (_box.length > maxLogEntries) {
var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
_box.deleteAt(0);
}
List<LoggerMessage> get messages {
final inDb =
_db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync();
return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb;
}
void _removeOverflowMessages() {
final msgCount = _db.loggerMessages.countSync();
if (msgCount > maxLogEntries) {
final numberOfEntryToBeDeleted = msgCount - maxLogEntries;
_db.writeTxn(
() => _db.loggerMessages
.where()
.limit(numberOfEntryToBeDeleted)
.deleteAll(),
);
}
}
_writeLogToHiveBox(LogRecord record) {
final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
var formattedMessage = record.message;
void _writeLogToDatabase(LogRecord record) {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
box.add(
ImmichLoggerMessage(
message: formattedMessage,
level: record.level.name,
createdAt: record.time,
context1: record.loggerName,
context2: record.stackTrace?.toString(),
),
final lm = LoggerMessage(
message: record.message,
level: record.level.toLogLevel(),
createdAt: record.time,
context1: record.loggerName,
context2: record.stackTrace?.toString(),
);
_msgBuffer.add(lm);
// delayed batch writing to database: increases performance when logging
// messages in quick succession and reduces NAND wear
_timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase);
}
void _flushBufferToDatabase() {
_timer = null;
final buffer = _msgBuffer;
_msgBuffer = [];
_db.writeTxn(() => _db.loggerMessages.putAll(buffer));
}
void clearLogs() {
_box.clear();
_timer?.cancel();
_timer = null;
_msgBuffer.clear();
_db.writeTxn(() => _db.loggerMessages.clear());
}
Future<void> shareLogs() async {
@@ -93,4 +112,12 @@ class ImmichLogger {
// Clean up temp file
await logFile.delete();
}
/// Flush pending log messages to persistent storage
void flush() {
if (_timer != null) {
_timer!.cancel();
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
}
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -61,8 +60,10 @@ class SyncService {
/// Syncs remote assets owned by the logged-in user to the DB
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) =>
_lock.run(() => _syncRemoteAssetsToDb(remote));
Future<bool> syncRemoteAssetsToDb(
FutureOr<List<Asset>?> Function() loadAssets,
) =>
_lock.run(() => _syncRemoteAssetsToDb(loadAssets));
/// Syncs remote albums to the database
/// returns `true` if there were any changes
@@ -97,19 +98,72 @@ class SyncService {
.toList();
}
/// Syncs a new asset to the db. Returns `true` if successful
Future<bool> syncNewAssetToDb(Asset newAsset) =>
_lock.run(() => _syncNewAssetToDb(newAsset));
// private methods:
/// Syncs a new asset to the db. Returns `true` if successful
Future<bool> _syncNewAssetToDb(Asset newAsset) async {
final List<Asset> inDb = await _db.assets
.where()
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
.findAll();
Asset? match;
if (inDb.length == 1) {
// exactly one match: trivial case
match = inDb.first;
} else if (inDb.length > 1) {
// TODO instead of this heuristics: match by checksum once available
for (Asset a in inDb) {
if (a.ownerId == newAsset.ownerId &&
a.fileModifiedAt == newAsset.fileModifiedAt) {
assert(match == null);
match = a;
}
}
if (match == null) {
for (Asset a in inDb) {
if (a.ownerId == newAsset.ownerId) {
assert(match == null);
match = a;
}
}
}
}
if (match != null) {
// unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset
newAsset.updateFromDb(match);
}
try {
await _db.writeTxn(() => newAsset.put(_db));
} on IsarError catch (e) {
_log.severe("Failed to put new asset into db: $e");
return false;
}
return true;
}
/// Syncs remote assets to the databas
/// returns `true` if there were any changes
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async {
Future<bool> _syncRemoteAssetsToDb(
FutureOr<List<Asset>?> Function() loadAssets,
) async {
final List<Asset>? remote = await loadAssets();
if (remote == null) {
return false;
}
final User user = Store.get(StoreKey.currentUser);
final List<Asset> inDb = await _db.assets
.filter()
.ownerIdEqualTo(user.isarId)
.sortByDeviceId()
.thenByLocalId()
.thenByFileModifiedAt()
.findAll();
remote.sort(Asset.compareByDeviceIdLocalId);
remote.sort(Asset.compareByOwnerDeviceLocalIdModified);
final diff = _diffAssets(remote, inDb, remote: true);
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
return false;
@@ -119,7 +173,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await _upsertAssetsWithExif(diff.first + diff.second);
} on IsarError catch (e) {
debugPrint(e.toString());
_log.severe("Failed to sync remote assets to db: $e");
}
return true;
}
@@ -188,10 +242,15 @@ class SyncService {
if (dto.assetCount != dto.assets.length) {
return false;
}
final assetsInDb =
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll();
final assetsInDb = await album.assets
.filter()
.sortByOwnerId()
.thenByDeviceId()
.thenByLocalId()
.thenByFileModifiedAt()
.findAll();
final List<Asset> assetsOnRemote = dto.getAssets();
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId);
assetsOnRemote.sort(Asset.compareByOwnerDeviceLocalIdModified);
final d = _diffAssets(assetsOnRemote, assetsInDb);
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
@@ -237,11 +296,11 @@ class SyncService {
await _db.albums.put(album);
});
} on IsarError catch (e) {
debugPrint(e.toString());
_log.severe("Failed to sync remote album to database $e");
}
if (album.shared || dto.shared) {
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
final userId = Store.get(StoreKey.currentUser).isarId;
final foreign =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
existing.addAll(foreign);
@@ -300,7 +359,7 @@ class SyncService {
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e) {
_log.warning("Failed to remove local album $album from DB");
_log.severe("Failed to remove local album $album from DB");
}
}
@@ -331,7 +390,7 @@ class SyncService {
_addAlbumFromDevice(ape, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
final pair = _handleAssetRemoval(deleteCandidates, existing);
final pair = _handleAssetRemoval(deleteCandidates, existing, remote: false);
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(pair.first);
@@ -366,7 +425,12 @@ class SyncService {
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets.filter().sortByLocalId().findAll();
final inDb = await album.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.deviceIdEqualTo(Store.get(StoreKey.deviceIdHash))
.sortByLocalId()
.findAll();
final List<Asset> onDevice =
await ape.getAssets(excludedAssets: excludedAssets);
onDevice.sort(Asset.compareByLocalId);
@@ -401,7 +465,7 @@ class SyncService {
});
_log.info("Synced changes of local album $ape to DB");
} on IsarError catch (e) {
_log.warning("Failed to update synced album $ape in DB: $e");
_log.severe("Failed to update synced album $ape in DB: $e");
}
return true;
@@ -438,7 +502,7 @@ class SyncService {
});
_log.info("Fast synced local album $ape to DB");
} on IsarError catch (e) {
_log.warning("Failed to fast sync local album $ape to DB: $e");
_log.severe("Failed to fast sync local album $ape to DB: $e");
return false;
}
@@ -470,7 +534,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: $ape");
} on IsarError catch (e) {
_log.warning("Failed to add new local album $ape to DB: $e");
_log.severe("Failed to add new local album $ape to DB: $e");
}
}
@@ -487,15 +551,19 @@ class SyncService {
assets,
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
)
.sortByDeviceId()
.sortByOwnerId()
.thenByDeviceId()
.thenByLocalId()
.thenByFileModifiedAt()
.findAll();
assets.sort(Asset.compareByDeviceIdLocalId);
assets.sort(Asset.compareByOwnerDeviceLocalIdModified);
final List<Asset> existing = [], toUpsert = [];
diffSortedListsSync(
inDb,
assets,
compare: Asset.compareByDeviceIdLocalId,
// do not compare by modified date because for some assets dates differ on
// client and server, thus never reaching "both" case below
compare: Asset.compareByOwnerDeviceLocalId,
both: (Asset a, Asset b) {
if ((a.isLocal || !b.isLocal) &&
(a.isRemote || !b.isRemote) &&
@@ -541,7 +609,7 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
List<Asset> assets,
List<Asset> inDb, {
bool? remote,
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId,
int Function(Asset, Asset) compare = Asset.compareByOwnerDeviceLocalId,
}) {
final List<Asset> toAdd = [];
final List<Asset> toUpdate = [];
@@ -582,15 +650,20 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
Pair<List<int>, List<Asset>> _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing,
) {
List<Asset> existing, {
bool? remote,
}) {
if (deleteCandidates.isEmpty) {
return const Pair([], []);
}
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
final triple =
_diffAssets(existing, deleteCandidates, compare: Asset.compareById);
final triple = _diffAssets(
existing,
deleteCandidates,
compare: Asset.compareById,
remote: remote,
);
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
}

View File

@@ -42,7 +42,7 @@ class UserService {
if (self) {
return _db.users.where().findAll();
}
final int userId = Store.get<User>(StoreKey.currentUser)!.isarId;
final int userId = Store.get(StoreKey.currentUser).isarId;
return _db.users.where().isarIdNotEqualTo(userId).findAll();
}

View File

@@ -1,9 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -84,7 +83,7 @@ class ImmichImage extends StatelessWidget {
},
);
}
final String? token = Hive.box(userInfoBox).get(accessTokenKey);
final String? token = Store.get(StoreKey.accessToken);
final String thumbnailRequestUrl = getThumbnailUrl(asset);
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:intl/intl.dart';
@@ -31,29 +32,29 @@ class AppLogPage extends HookConsumerWidget {
);
}
Widget buildLeadingIcon(String level) {
Widget buildLeadingIcon(LogLevel level) {
switch (level) {
case "INFO":
case LogLevel.INFO:
return colorStatusIndicator(Theme.of(context).primaryColor);
case "SEVERE":
case LogLevel.SEVERE:
return colorStatusIndicator(Colors.redAccent);
case "WARNING":
case LogLevel.WARNING:
return colorStatusIndicator(Colors.orangeAccent);
default:
return colorStatusIndicator(Colors.grey);
}
}
getTileColor(String level) {
getTileColor(LogLevel level) {
switch (level) {
case "INFO":
case LogLevel.INFO:
return Colors.transparent;
case "SEVERE":
case LogLevel.SEVERE:
return Theme.of(context).brightness == Brightness.dark
? Colors.redAccent.withOpacity(0.25)
: Colors.redAccent.withOpacity(0.075);
case "WARNING":
case LogLevel.WARNING:
return Theme.of(context).brightness == Brightness.dark
? Colors.orangeAccent.withOpacity(0.25)
: Colors.orangeAccent.withOpacity(0.075);

View File

@@ -1,14 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
class SplashScreenPage extends HookConsumerWidget {
@@ -17,23 +15,23 @@ class SplashScreenPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiService = ref.watch(apiServiceProvider);
HiveSavedLoginInfo? loginInfo =
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final accessToken = Store.tryGet(StoreKey.accessToken);
void performLoggingIn() async {
bool isSuccess = false;
if (loginInfo != null) {
if (accessToken != null && serverUrl != null) {
try {
// Resolve API server endpoint from user provided serverUrl
await apiService.resolveAndSetEndpoint(loginInfo.serverUrl);
await apiService.resolveAndSetEndpoint(serverUrl);
} catch (e) {
// okay, try to continue anyway if offline
}
isSuccess =
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
accessToken: loginInfo.accessToken,
serverUrl: loginInfo.serverUrl,
accessToken: accessToken,
serverUrl: serverUrl,
);
}
if (isSuccess) {
@@ -51,7 +49,7 @@ class SplashScreenPage extends HookConsumerWidget {
useEffect(
() {
if (loginInfo != null) {
if (serverUrl != null && accessToken != null) {
performLoggingIn();
} else {
AutoRouter.of(context).replace(const LoginRoute());

View File

@@ -169,7 +169,10 @@ class TabControllerPage extends ConsumerWidget {
);
}
return Scaffold(
body: body,
body: HeroControllerScope(
controller: HeroController(),
child: body,
),
bottomNavigationBar: multiselectEnabled ? null : bottom,
);
},

View File

@@ -3,12 +3,17 @@ import 'dart:async';
/// Async mutex to guarantee actions are performed sequentially and do not interleave
class AsyncMutex {
Future _running = Future.value(null);
int _enqueued = 0;
get enqueued => _enqueued;
/// Execute [operation] exclusively, after any currently running operations.
/// Returns a [Future] with the result of the [operation].
Future<T> run<T>(Future<T> Function() operation) {
final completer = Completer<T>();
_enqueued++;
_running.whenComplete(() {
_enqueued--;
completer.complete(Future<T>.sync(operation));
});
return _running = completer.future;

14
mobile/lib/utils/db.dart Normal file
View File

@@ -0,0 +1,14 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:isar/isar.dart';
Future<void> clearAssetsAndAlbums(Isar db) async {
await Store.delete(StoreKey.assetETag);
await db.writeTxn(() async {
await db.assets.clear();
await db.exifInfos.clear();
await db.albums.clear();
});
}

View File

@@ -44,6 +44,9 @@ class FileHelper {
case '3gp':
return {"type": "video", "subType": "3gpp"};
case 'webm':
return {"type": "video", "subType": "webm"};
default:
return {"type": "unsupport", "subType": "unsupport"};
}

View File

@@ -1,10 +1,8 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:openapi/api.dart';
import '../constants/hive_box.dart';
String getThumbnailUrl(
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
@@ -48,8 +46,7 @@ String getAlbumThumbNailCacheKey(
}
String getImageUrl(final Asset asset) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}?isThumb=false';
return '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}?isThumb=false';
}
String getImageCacheKey(final Asset asset) {
@@ -60,7 +57,5 @@ String _getThumbnailUrl(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/thumbnail/$id?format=${type.value}';
return '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$id?format=${type.value}';
}

View File

@@ -41,6 +41,8 @@ ThemeData immichLightTheme = ThemeData(
titleTextStyle: const TextStyle(
fontFamily: 'WorkSans',
color: Colors.indigo,
fontWeight: FontWeight.bold,
fontSize: 18,
),
backgroundColor: immichBackgroundColor,
foregroundColor: Colors.indigo,
@@ -75,6 +77,18 @@ ThemeData immichLightTheme = ThemeData(
fontWeight: FontWeight.bold,
color: Colors.indigo,
),
titleSmall: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
),
titleMedium: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
titleLarge: TextStyle(
fontSize: 26.0,
fontWeight: FontWeight.bold,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
@@ -127,6 +141,8 @@ ThemeData immichDarkTheme = ThemeData(
titleTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: immichDarkThemePrimaryColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
backgroundColor: const Color.fromARGB(255, 32, 33, 35),
foregroundColor: immichDarkThemePrimaryColor,
@@ -159,6 +175,18 @@ ThemeData immichDarkTheme = ThemeData(
fontWeight: FontWeight.bold,
color: immichDarkThemePrimaryColor,
),
titleSmall: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
),
titleMedium: const TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
titleLarge: const TextStyle(
fontSize: 26.0,
fontWeight: FontWeight.bold,
),
),
cardColor: Colors.grey[900],
elevatedButtonTheme: ElevatedButtonThemeData(

View File

@@ -1,5 +1,7 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -8,8 +10,12 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
Future<void> migrateHiveToStoreIfNecessary() async {
@@ -23,11 +29,36 @@ Future<void> migrateHiveToStoreIfNecessary() async {
duplicatedAssetsBox,
_migrateDuplicatedAssetsBox,
);
await _migrateHiveBoxIfNecessary(
hiveGithubReleaseInfoBox,
_migrateReleaseInfoBox,
);
await _migrateHiveBoxIfNecessary(hiveLoginInfoBox, _migrateLoginInfoBox);
await _migrateHiveBoxIfNecessary(
immichLoggerBox,
(Box<ImmichLoggerMessage> box) => box.deleteFromDisk(),
);
await _migrateHiveBoxIfNecessary(userSettingInfoBox, _migrateAppSettingsBox);
}
FutureOr<void> _migrateReleaseInfoBox(Box box) =>
_migrateKey(box, githubReleaseInfoKey, StoreKey.githubReleaseInfo);
Future<void> _migrateLoginInfoBox(Box<HiveSavedLoginInfo> box) async {
final HiveSavedLoginInfo? info = box.get(savedLoginInfoKey);
if (info != null) {
await Store.put(StoreKey.serverUrl, info.serverUrl);
await Store.put(StoreKey.accessToken, info.accessToken);
}
}
Future<void> _migrateHiveUserInfoBox(Box box) async {
await _migrateKey(box, userIdKey, StoreKey.userRemoteId);
await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
if (Store.tryGet(StoreKey.deviceId) == null) {
await _migrateKey(box, deviceIdKey, StoreKey.deviceId);
}
await _migrateKey(box, serverEndpointKey, StoreKey.serverEndpoint);
}
Future<void> _migrateHiveBackgroundBackupInfoBox(Box box) async {
@@ -35,16 +66,15 @@ Future<void> _migrateHiveBackgroundBackupInfoBox(Box box) async {
await _migrateKey(box, backupRequireWifi, StoreKey.backupRequireWifi);
await _migrateKey(box, backupRequireCharging, StoreKey.backupRequireCharging);
await _migrateKey(box, backupTriggerDelay, StoreKey.backupTriggerDelay);
return box.deleteFromDisk();
}
Future<void> _migrateBackupInfoBox(Box<HiveBackupAlbums> box) async {
final Isar? db = Isar.getInstance();
if (db == null) {
throw Exception("_migrateBackupInfoBox could not load database");
}
FutureOr<void> _migrateBackupInfoBox(Box<HiveBackupAlbums> box) {
final HiveBackupAlbums? infos = box.get(backupInfoKey);
if (infos != null) {
final Isar? db = Isar.getInstance();
if (db == null) {
throw Exception("_migrateBackupInfoBox could not load database");
}
List<BackupAlbum> albums = [];
for (int i = 0; i < infos.selectedAlbumIds.length; i++) {
final album = BackupAlbum(
@@ -62,48 +92,49 @@ Future<void> _migrateBackupInfoBox(Box<HiveBackupAlbums> box) async {
);
albums.add(album);
}
await db.writeTxn(() => db.backupAlbums.putAll(albums));
} else {
debugPrint("_migrateBackupInfoBox deletes empty box");
return db.writeTxn(() => db.backupAlbums.putAll(albums));
}
return box.deleteFromDisk();
}
Future<void> _migrateDuplicatedAssetsBox(Box<HiveDuplicatedAssets> box) async {
final Isar? db = Isar.getInstance();
if (db == null) {
throw Exception("_migrateBackupInfoBox could not load database");
}
FutureOr<void> _migrateDuplicatedAssetsBox(Box<HiveDuplicatedAssets> box) {
final HiveDuplicatedAssets? duplicatedAssets = box.get(duplicatedAssetsKey);
if (duplicatedAssets != null) {
final Isar? db = Isar.getInstance();
if (db == null) {
throw Exception("_migrateBackupInfoBox could not load database");
}
final duplicatedAssetIds = duplicatedAssets.duplicatedAssetIds
.map((id) => DuplicatedAsset(id))
.toList();
await db.writeTxn(() => db.duplicatedAssets.putAll(duplicatedAssetIds));
} else {
debugPrint("_migrateDuplicatedAssetsBox deletes empty box");
return db.writeTxn(() => db.duplicatedAssets.putAll(duplicatedAssetIds));
}
}
Future<void> _migrateAppSettingsBox(Box box) async {
for (AppSettingsEnum s in AppSettingsEnum.values) {
await _migrateKey(box, s.hiveKey, s.storeKey);
}
return box.deleteFromDisk();
}
Future<void> _migrateHiveBoxIfNecessary<T>(
String boxName,
Future<void> Function(Box<T>) migrate,
FutureOr<void> Function(Box<T>) migrate,
) async {
try {
if (await Hive.boxExists(boxName)) {
await migrate(await Hive.openBox<T>(boxName));
final box = await Hive.openBox<T>(boxName);
await migrate(box);
await box.deleteFromDisk();
}
} catch (e) {
debugPrint("Error while migrating $boxName $e");
}
}
_migrateKey(Box box, String hiveKey, StoreKey key) async {
final String? value = box.get(hiveKey);
FutureOr<void> _migrateKey<T>(Box box, String hiveKey, StoreKey<T> key) {
final T? value = box.get(hiveKey);
if (value != null) {
await Store.put(key, value);
await box.delete(hiveKey);
return Store.put(key, value);
}
}
@@ -112,3 +143,16 @@ Future<void> migrateJsonCacheIfNecessary() async {
await SharedAlbumCacheService().invalidate();
await AssetCacheService().invalidate();
}
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);
switch (version) {
case 1:
await _migrateV1ToV2(db);
}
}
Future<void> _migrateV1ToV2(Isar db) async {
await clearAssetsAndAlbums(db);
await Store.put(StoreKey.version, 2);
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.51.1
- API version: 1.51.2
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -477,7 +477,7 @@ import 'package:openapi/api.dart';
final api_instance = AlbumApi();
final shared = true; // bool |
final assetId = assetId_example; // String | Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
try {
final result = api_instance.getAllAlbums(shared, assetId);

View File

@@ -25,6 +25,16 @@ Method | HTTP request | Description
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
final api_instance = ServerInfoApi();
@@ -45,7 +55,7 @@ This endpoint does not need any parameter.
### Authorization
No authorization required
[bearer](../README.md#bearer), [cookie](../README.md#cookie)
### HTTP request headers

View File

@@ -13,7 +13,7 @@ Name | Type | Description | Notes
**targetVideoCodec** | **String** | |
**targetAudioCodec** | **String** | |
**targetScaling** | **String** | |
**transcodeAll** | **bool** | |
**transcode** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -292,6 +292,16 @@ This endpoint does not need any parameter.
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
final api_instance = UserApi();
final userId = userId_example; // String |
@@ -316,7 +326,7 @@ Name | Type | Description | Notes
### Authorization
No authorization required
[bearer](../README.md#bearer), [cookie](../README.md#cookie)
### HTTP request headers
@@ -335,6 +345,16 @@ No authorization required
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
final api_instance = UserApi();
final userId = userId_example; // String |
@@ -359,7 +379,7 @@ Name | Type | Description | Notes
### Authorization
No authorization required
[bearer](../README.md#bearer), [cookie](../README.md#cookie)
### HTTP request headers

View File

@@ -25,12 +25,14 @@ class JobCommand {
static const start = JobCommand._(r'start');
static const pause = JobCommand._(r'pause');
static const resume = JobCommand._(r'resume');
static const empty = JobCommand._(r'empty');
/// List of all possible values in this [enum][JobCommand].
static const values = <JobCommand>[
start,
pause,
resume,
empty,
];
@@ -72,6 +74,7 @@ class JobCommandTypeTransformer {
switch (data.toString()) {
case r'start': return JobCommand.start;
case r'pause': return JobCommand.pause;
case r'resume': return JobCommand.resume;
case r'empty': return JobCommand.empty;
default:
if (!allowNull) {

View File

@@ -18,7 +18,7 @@ class SystemConfigFFmpegDto {
required this.targetVideoCodec,
required this.targetAudioCodec,
required this.targetScaling,
required this.transcodeAll,
required this.transcode,
});
String crf;
@@ -31,7 +31,7 @@ class SystemConfigFFmpegDto {
String targetScaling;
bool transcodeAll;
SystemConfigFFmpegDtoTranscodeEnum transcode;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegDto &&
@@ -40,7 +40,7 @@ class SystemConfigFFmpegDto {
other.targetVideoCodec == targetVideoCodec &&
other.targetAudioCodec == targetAudioCodec &&
other.targetScaling == targetScaling &&
other.transcodeAll == transcodeAll;
other.transcode == transcode;
@override
int get hashCode =>
@@ -50,10 +50,10 @@ class SystemConfigFFmpegDto {
(targetVideoCodec.hashCode) +
(targetAudioCodec.hashCode) +
(targetScaling.hashCode) +
(transcodeAll.hashCode);
(transcode.hashCode);
@override
String toString() => 'SystemConfigFFmpegDto[crf=$crf, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetScaling=$targetScaling, transcodeAll=$transcodeAll]';
String toString() => 'SystemConfigFFmpegDto[crf=$crf, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetScaling=$targetScaling, transcode=$transcode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -62,7 +62,7 @@ class SystemConfigFFmpegDto {
json[r'targetVideoCodec'] = this.targetVideoCodec;
json[r'targetAudioCodec'] = this.targetAudioCodec;
json[r'targetScaling'] = this.targetScaling;
json[r'transcodeAll'] = this.transcodeAll;
json[r'transcode'] = this.transcode;
return json;
}
@@ -90,7 +90,7 @@ class SystemConfigFFmpegDto {
targetVideoCodec: mapValueOfType<String>(json, r'targetVideoCodec')!,
targetAudioCodec: mapValueOfType<String>(json, r'targetAudioCodec')!,
targetScaling: mapValueOfType<String>(json, r'targetScaling')!,
transcodeAll: mapValueOfType<bool>(json, r'transcodeAll')!,
transcode: SystemConfigFFmpegDtoTranscodeEnum.fromJson(json[r'transcode'])!,
);
}
return null;
@@ -145,7 +145,84 @@ class SystemConfigFFmpegDto {
'targetVideoCodec',
'targetAudioCodec',
'targetScaling',
'transcodeAll',
'transcode',
};
}
class SystemConfigFFmpegDtoTranscodeEnum {
/// Instantiate a new enum with the provided [value].
const SystemConfigFFmpegDtoTranscodeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const all = SystemConfigFFmpegDtoTranscodeEnum._(r'all');
static const optimal = SystemConfigFFmpegDtoTranscodeEnum._(r'optimal');
static const required_ = SystemConfigFFmpegDtoTranscodeEnum._(r'required');
/// List of all possible values in this [enum][SystemConfigFFmpegDtoTranscodeEnum].
static const values = <SystemConfigFFmpegDtoTranscodeEnum>[
all,
optimal,
required_,
];
static SystemConfigFFmpegDtoTranscodeEnum? fromJson(dynamic value) => SystemConfigFFmpegDtoTranscodeEnumTypeTransformer().decode(value);
static List<SystemConfigFFmpegDtoTranscodeEnum>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigFFmpegDtoTranscodeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigFFmpegDtoTranscodeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [SystemConfigFFmpegDtoTranscodeEnum] to String,
/// and [decode] dynamic data back to [SystemConfigFFmpegDtoTranscodeEnum].
class SystemConfigFFmpegDtoTranscodeEnumTypeTransformer {
factory SystemConfigFFmpegDtoTranscodeEnumTypeTransformer() => _instance ??= const SystemConfigFFmpegDtoTranscodeEnumTypeTransformer._();
const SystemConfigFFmpegDtoTranscodeEnumTypeTransformer._();
String encode(SystemConfigFFmpegDtoTranscodeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a SystemConfigFFmpegDtoTranscodeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
SystemConfigFFmpegDtoTranscodeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data.toString()) {
case r'all': return SystemConfigFFmpegDtoTranscodeEnum.all;
case r'optimal': return SystemConfigFFmpegDtoTranscodeEnum.optimal;
case r'required': return SystemConfigFFmpegDtoTranscodeEnum.required_;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [SystemConfigFFmpegDtoTranscodeEnumTypeTransformer] instance.
static SystemConfigFFmpegDtoTranscodeEnumTypeTransformer? _instance;
}

View File

@@ -41,8 +41,8 @@ void main() {
// TODO
});
// bool transcodeAll
test('to test the property `transcodeAll`', () async {
// String transcode
test('to test the property `transcode`', () async {
// TODO
});

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.51.2+74
version: 1.52.0+75
isar_version: &isar_version 3.0.5
environment:

View File

@@ -20,6 +20,7 @@ void main() {
fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0,
type: AssetType.image,
fileName: '',
isFavorite: false,
isLocal: false,

View File

@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
void main() {
group('Test AsyncMutex grouped', () {
test('test ordered execution', () async {
AsyncMutex lock = AsyncMutex();
List<int> events = [];
expect(0, lock.enqueued);
lock.run(
() => Future.delayed(
const Duration(milliseconds: 10),
() => events.add(1),
),
);
expect(1, lock.enqueued);
lock.run(
() => Future.delayed(
const Duration(milliseconds: 3),
() => events.add(2),
),
);
expect(2, lock.enqueued);
lock.run(
() => Future.delayed(
const Duration(milliseconds: 1),
() => events.add(3),
),
);
expect(3, lock.enqueued);
await lock.run(
() => Future.delayed(
const Duration(milliseconds: 10),
() => events.add(4),
),
);
expect(0, lock.enqueued);
expect(events, [1, 2, 3, 4]);
});
});
}

View File

@@ -23,6 +23,7 @@ Asset _getTestAsset(int id, bool favorite) {
updatedAt: DateTime.now(),
isLocal: false,
durationInSeconds: 0,
type: AssetType.image,
fileName: '',
isFavorite: favorite,
);

View File

@@ -187,6 +187,16 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> getAllAsset({bool? clear = false}) => (super.noSuchMethod(
Invocation.method(
#getAllAsset,
[],
{#clear: clear},
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> clearAllAsset() => (super.noSuchMethod(
Invocation.method(
#clearAllAsset,

View File

@@ -0,0 +1,143 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:isar/isar.dart';
void main() {
Asset makeAsset({
required String localId,
String? remoteId,
int deviceId = 1,
int ownerId = 590700560494856554, // hash of "1"
bool isLocal = false,
}) {
final DateTime date = DateTime(2000);
return Asset(
localId: localId,
remoteId: remoteId,
deviceId: deviceId,
ownerId: ownerId,
fileCreatedAt: date,
fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0,
type: AssetType.image,
fileName: localId,
isFavorite: false,
isLocal: isLocal,
);
}
Isar loadDb() {
return Isar.openSync(
[
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
StoreValueSchema,
LoggerMessageSchema
],
maxSizeMiB: 256,
);
}
group('Test SyncService grouped', () {
late final Isar db;
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
await Isar.initializeIsarCore(download: true);
db = loadDb();
ImmichLogger();
db.writeTxnSync(() => db.clearSync());
Store.init(db);
await Store.put(
StoreKey.currentUser,
User(
id: "1",
updatedAt: DateTime.now(),
email: "a@b.c",
firstName: "first",
lastName: "last",
isAdmin: false,
),
);
});
final List<Asset> initialAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "1-1", isLocal: true),
makeAsset(localId: "2", isLocal: true),
makeAsset(localId: "3", isLocal: true),
];
setUp(() {
db.writeTxnSync(() {
db.assets.clearSync();
db.assets.putAllSync(initialAssets);
});
});
test('test inserting existing assets', () async {
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "1-1"),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c1, false);
expect(db.assets.countSync(), 5);
});
test('test inserting new assets', () async {
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "1-1"),
makeAsset(localId: "2", remoteId: "1-2"),
makeAsset(localId: "4", remoteId: "1-4"),
makeAsset(localId: "1", remoteId: "3-1", deviceId: 3),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 7);
});
test('test syncing duplicate assets', () async {
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "1-1"),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "2-1b", deviceId: 2),
makeAsset(localId: "1", remoteId: "2-1c", deviceId: 2),
makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 8);
final bool c2 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c2, false);
expect(db.assets.countSync(), 8);
remoteAssets.removeAt(4);
final bool c3 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c3, true);
expect(db.assets.countSync(), 7);
remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2));
remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2));
final bool c4 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c4, true);
expect(db.assets.countSync(), 9);
});
});
}

View File

@@ -6,11 +6,7 @@ import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './comma
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command';
@Module({
imports: [
DomainModule.register({
imports: [InfraModule],
}),
],
imports: [DomainModule.register({ imports: [InfraModule] })],
providers: [
ResetAdminPasswordCommand,
PromptPasswordQuestions,

View File

@@ -1,11 +1,11 @@
import { AlbumEntity, AssetEntity, dataSource, UserEntity } from '@app/infra';
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
import { dataSource } from '@app/infra/db/config';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, IsNull, FindManyOptions } from 'typeorm';
import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { CreateAlbumDto } from './dto/create-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
@@ -13,8 +13,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
getPublicSharingList(ownerId: string): Promise<AlbumEntity[]>;
get(albumId: string): Promise<AlbumEntity | null>;
delete(album: AlbumEntity): Promise<void>;
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
@@ -23,7 +21,6 @@ export interface IAlbumRepository {
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
updateThumbnails(): Promise<number | undefined>;
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
}
@@ -40,22 +37,6 @@ export class AlbumRepository implements IAlbumRepository {
private assetRepository: Repository<AssetEntity>,
) {}
async getPublicSharingList(ownerId: string): Promise<AlbumEntity[]> {
return this.albumRepository.find({
relations: {
sharedLinks: true,
assets: true,
owner: true,
},
where: {
ownerId,
sharedLinks: {
id: Not(IsNull()),
},
},
});
}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } });
@@ -77,59 +58,6 @@ export class AlbumRepository implements IAlbumRepository {
return this.get(album.id) as Promise<AlbumEntity>;
}
async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
const userId = ownerId;
const queryProperties: FindManyOptions<AlbumEntity> = {
relations: { sharedUsers: true, assets: true, sharedLinks: true, owner: true },
select: { assets: { id: true } },
order: { createdAt: 'DESC' },
};
let albumsQuery: Promise<AlbumEntity[]>;
/**
* `shared` boolean usage
* true = shared with me, and my albums that are shared
* false = my albums that are not shared
* undefined = all my albums
*/
if (filteringByShared) {
if (getAlbumsDto.shared) {
// shared albums
albumsQuery = this.albumRepository.find({
where: [{ sharedUsers: { id: userId } }, { ownerId: userId, sharedUsers: { id: Not(IsNull()) } }],
...queryProperties,
});
} else {
// owned, not shared albums
albumsQuery = this.albumRepository.find({
where: { ownerId: userId, sharedUsers: { id: IsNull() } },
...queryProperties,
});
}
} else {
// owned
albumsQuery = this.albumRepository.find({
where: { ownerId: userId },
...queryProperties,
});
}
return albumsQuery;
}
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
const albums = await this.albumRepository.find({
where: { ownerId: userId },
relations: { owner: true, assets: true, sharedUsers: true },
order: { assets: { fileCreatedAt: 'ASC' } },
});
return albums.filter((album) => album.assets.some((asset) => asset.id === assetId));
}
async get(albumId: string): Promise<AlbumEntity | null> {
return this.albumRepository.findOne({
where: { id: albumId },

View File

@@ -21,7 +21,6 @@ import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
@@ -74,15 +73,6 @@ export class AlbumController {
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
}
@Authenticated()
@Get()
async getAllAlbums(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
) {
return this.albumService.getAllAlbums(authUser, query);
}
@Authenticated({ isShared: true })
@Get('/:albumId')
async getAlbumInfo(

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AlbumService } from './album.service';
import { AlbumController } from './album.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlbumEntity, AssetEntity } from '@app/infra';
import { AlbumEntity, AssetEntity } from '@app/infra/db/entities';
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { DownloadModule } from '../../modules/download/download.module';

View File

@@ -1,14 +1,13 @@
import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra';
import { AlbumEntity, UserEntity } from '@app/infra/db/entities';
import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
import { ISharedLinkRepository } from '@app/domain';
import {
assetEntityStub,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
@@ -119,18 +118,15 @@ describe('Album service', () => {
beforeAll(() => {
albumRepositoryMock = {
getPublicSharingList: jest.fn(),
addAssets: jest.fn(),
addSharedUsers: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
get: jest.fn(),
getList: jest.fn(),
removeAssets: jest.fn(),
removeUser: jest.fn(),
updateAlbum: jest.fn(),
updateThumbnails: jest.fn(),
getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(),
};
@@ -166,19 +162,6 @@ describe('Album service', () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [albumEntity.id] } });
});
it('gets list of albums for auth user', async () => {
const ownedAlbum = _getOwnedAlbum();
const ownedSharedAlbum = _getOwnedSharedAlbum();
const sharedWithMeAlbum = _getSharedWithAuthUserAlbum();
const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum];
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(ownedAlbum.id);
});
it('gets an owned album', async () => {
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
@@ -474,76 +457,4 @@ describe('Album service', () => {
),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('counts assets correctly', async () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = authUser.id;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = 'date';
albumEntity.sharedUsers = [];
albumEntity.assets = [
{
...assetEntityStub.image,
id: '3',
},
];
albumEntity.albumThumbnailAssetId = null;
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve([albumEntity]));
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1);
});
it('updates the album thumbnail by listing all albums', async () => {
const albumEntity = _getOwnedAlbum();
const assetEntity = new AssetEntity();
const newThumbnailAsset = new AssetEntity();
newThumbnailAsset.id = 'e5e65c02-b889-4f3c-afe1-a39a96d578ed';
albumEntity.albumThumbnailAssetId = 'nonexistent';
assetEntity.id = newThumbnailAsset.id;
albumEntity.assets = [assetEntity];
albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]);
albumRepositoryMock.updateThumbnails.mockImplementation(async () => {
albumEntity.albumThumbnailAsset = newThumbnailAsset;
albumEntity.albumThumbnailAssetId = newThumbnailAsset.id;
return 1;
});
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(1);
expect(result[0].albumThumbnailAssetId).toEqual(newThumbnailAsset.id);
expect(albumRepositoryMock.getList).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.updateThumbnails).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.getList).toHaveBeenCalledWith(albumEntity.ownerId, {});
});
it('removes the thumbnail for an empty album', async () => {
const albumEntity = _getOwnedAlbum();
albumEntity.albumThumbnailAssetId = 'e5e65c02-b889-4f3c-afe1-a39a96d578ed';
albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]);
albumRepositoryMock.updateThumbnails.mockImplementation(async () => {
albumEntity.albumThumbnailAsset = null;
albumEntity.albumThumbnailAssetId = null;
return 1;
});
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(1);
expect(result[0].albumThumbnailAssetId).toBeNull();
expect(albumRepositoryMock.getList).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.updateThumbnails).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.getList).toHaveBeenCalledWith(albumEntity.ownerId, {});
});
});

View File

@@ -1,12 +1,11 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAlbumDto } from './dto/create-album.dto';
import { AlbumEntity, SharedLinkType } from '@app/infra';
import { AlbumEntity, SharedLinkType } from '@app/infra/db/entities';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum } from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@@ -15,7 +14,6 @@ import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import _ from 'lodash';
@Injectable()
export class AlbumService {
@@ -63,31 +61,6 @@ export class AlbumService {
return mapAlbum(albumEntity);
}
/**
* Get all shared album, including owned and shared one.
* @param authUser AuthUserDto
* @returns All Shared Album And Its Members
*/
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: AlbumEntity[];
if (typeof getAlbumsDto.assetId === 'string') {
albums = await this.albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId);
} else {
albums = await this.albumRepository.getList(authUser.id, getAlbumsDto);
if (getAlbumsDto.shared) {
const publicSharingAlbums = await this.albumRepository.getPublicSharingList(authUser.id);
albums = [...albums, ...publicSharingAlbums];
}
}
albums = _.uniqBy(albums, (album) => album.id);
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
}
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return mapAlbum(album);

View File

@@ -1,6 +1,6 @@
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetEntity, AssetType } from '@app/infra';
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/repository/Repository';

View File

@@ -1,29 +1,10 @@
import {
AuthUserDto,
IJobRepository,
IStorageRepository,
ISystemConfigRepository,
JobName,
StorageTemplateCore,
} from '@app/domain';
import { AssetEntity, SystemConfig, UserEntity } from '@app/infra/db/entities';
import { Logger } from '@nestjs/common';
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, UserEntity } from '@app/infra/db/entities';
import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
export class AssetCore {
private templateCore: StorageTemplateCore;
private logger = new Logger(AssetCore.name);
constructor(
private repository: IAssetRepository,
private jobRepository: IJobRepository,
configRepository: ISystemConfigRepository,
config: SystemConfig,
private storageRepository: IStorageRepository,
) {
this.templateCore = new StorageTemplateCore(configRepository, config, storageRepository);
}
constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
async create(
authUser: AuthUserDto,
@@ -31,7 +12,7 @@ export class AssetCore {
file: UploadFile,
livePhotoAssetId?: string,
): Promise<AssetEntity> {
let asset = await this.repository.create({
const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity,
mimeType: file.mimeType,
@@ -56,31 +37,8 @@ export class AssetCore {
sharedLinks: [],
});
asset = await this.moveAsset(asset, file.originalName);
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } });
return asset;
}
async moveAsset(asset: AssetEntity, originalName: string) {
const destination = await this.templateCore.getTemplatePath(asset, originalName);
if (asset.originalPath !== destination) {
const source = asset.originalPath;
try {
await this.storageRepository.moveFile(asset.originalPath, destination);
try {
await this.repository.save({ id: asset.id, originalPath: destination });
asset.originalPath = destination;
} catch (error: any) {
this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack);
await this.storageRepository.moveFile(destination, source);
}
} catch (error: any) {
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination });
}
}
return asset;
}
}

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
import { AssetRepository, IAssetRepository } from './asset-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { TagModule } from '../tag/tag.module';

View File

@@ -1,21 +1,14 @@
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { QueryFailedError, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/infra';
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import {
ICryptoRepository,
IJobRepository,
ISharedLinkRepository,
IStorageRepository,
ISystemConfigRepository,
JobName,
} from '@app/domain';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain';
import {
assetEntityStub,
authStub,
@@ -24,10 +17,8 @@ import {
newJobRepositoryMock,
newSharedLinkRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
systemConfigStub,
} from '@app/domain/../test';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
@@ -121,7 +112,6 @@ describe('AssetService', () => {
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
@@ -160,7 +150,6 @@ describe('AssetService', () => {
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
configMock = newSystemConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
@@ -171,8 +160,6 @@ describe('AssetService', () => {
downloadServiceMock as DownloadService,
sharedLinkRepositoryMock,
jobMock,
configMock,
systemConfigStub.defaults,
cryptoMock,
storageMock,
);
@@ -273,10 +260,6 @@ describe('AssetService', () => {
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
expect(assetRepositoryMock.save).toHaveBeenCalledWith({
id: 'id_1',
originalPath: 'upload/user_id_1/2022/2022-06-19/asset_1.jpeg',
});
});
it('should handle a duplicate', async () => {

Some files were not shown because too many files have changed in this diff Show More