mirror of
https://github.com/immich-app/immich.git
synced 2025-12-09 01:10:08 +03:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d17302bc | ||
|
|
cc3ffcbb84 | ||
|
|
eda9e580c9 | ||
|
|
76a07a3ebc | ||
|
|
abe87686a2 | ||
|
|
6371c11fc5 | ||
|
|
d5596cf6a2 | ||
|
|
2f64af9cb2 | ||
|
|
b0d5c7035b | ||
|
|
0c61521521 | ||
|
|
3497a0de54 | ||
|
|
117f2fa00d | ||
|
|
2c67090e3c | ||
|
|
10ccbeab35 | ||
|
|
b49f66bbc9 | ||
|
|
9adbbd42be | ||
|
|
8563bd463c | ||
|
|
da5a6d2272 | ||
|
|
0854737be2 | ||
|
|
6f3f8b0a48 | ||
|
|
f0e272d0f2 | ||
|
|
5e207aa7c1 | ||
|
|
e0b80f49b6 | ||
|
|
97bbe42599 | ||
|
|
089dbdbd7e | ||
|
|
833c099025 | ||
|
|
4e526dfaae | ||
|
|
cae37657e9 | ||
|
|
1a94530935 | ||
|
|
75d28d3c58 | ||
|
|
cd59f7aad6 | ||
|
|
2f9fcd96c7 | ||
|
|
c74fba483d | ||
|
|
193dd01e06 | ||
|
|
2400004f41 | ||
|
|
b862c20e8e | ||
|
|
c0ed623d26 | ||
|
|
501b96baf7 | ||
|
|
d2600e0ddd | ||
|
|
7d799b785e | ||
|
|
e36b620020 | ||
|
|
1efc74dabc | ||
|
|
54f98053a8 | ||
|
|
6745826f35 | ||
|
|
bbd897b8ff | ||
|
|
586590e9ec | ||
|
|
4bf50a0b46 | ||
|
|
40832f0ea7 | ||
|
|
32a065afc7 | ||
|
|
4dafc74223 | ||
|
|
8adf1231a3 | ||
|
|
c00624f209 | ||
|
|
eccde8fa07 | ||
|
|
0616a66b05 | ||
|
|
67453d18ff | ||
|
|
792a87e407 | ||
|
|
6da50626e1 | ||
|
|
6239b3b309 | ||
|
|
b9bc621e2a | ||
|
|
e10bbfa933 | ||
|
|
2dd301e292 |
5
.github/workflows/prepare-release.yml
vendored
5
.github/workflows/prepare-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Deployment checklist for iOS/Android/Server
|
||||
|
||||
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
|
||||
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
|
||||
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
|
||||
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
|
||||
|
||||
All of the version should be the same.
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
entrypoint: [ "/bin/sh", "./start-server.sh" ]
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
|
||||
entrypoint: ["/bin/sh", "./start-microservices.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
immich-web:
|
||||
container_name: immich_web
|
||||
image: ghcr.io/immich-app/immich-web:release
|
||||
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
@@ -63,6 +63,7 @@ services:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
|
||||
@@ -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.
|
||||
|
||||
22
docs/docs/administration/reverse-proxy.md
Normal file
22
docs/docs/administration/reverse-proxy.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Reverse Proxy
|
||||
|
||||
When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
|
||||
|
||||
## Default Reverse Proxy
|
||||
|
||||
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
|
||||
|
||||
## Using a Different Reverse Proxy
|
||||
|
||||
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
|
||||
|
||||
1. Add another reverse proxy on top of Immich's reverse proxy
|
||||
2. Completely replace the default reverse proxy
|
||||
|
||||
## Adding a Custom Reverse Proxy
|
||||
|
||||
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
||||
|
||||
## Replacing the Default Reverse Proxy
|
||||
|
||||
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 74,
|
||||
"android.injected.version.name" => "1.51.1",
|
||||
"android.injected.version.code" => 75,
|
||||
"android.injected.version.name" => "1.52.1",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
* refactor: migrate all Hive boxes to Isar database.
|
||||
* feat: Use new search API and GridView for Places / Locations.
|
||||
* refactor: store backup settings on device.
|
||||
* feat: Explore favorites, recently added, videos, and motion photos.
|
||||
* fix: Fixed mobile app not reporting webm MIME type.
|
||||
* feature: Hardening synchronization mechanism + Pull to refresh.
|
||||
* feat: improve explore page and allow metadata search.
|
||||
* feat: Allow headers to upload large file in chunk.
|
||||
@@ -5,19 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000232">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000285">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.685298">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="168.230955">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="33.624781">
|
||||
|
||||
<failure message="/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `chdir' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:42:in `block (2 levels) in parsing_binding' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane.rb:33:in `call' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `chdir' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `execute' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/bin/fastlane:23:in `<top (required)>' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `load' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `<main>' Google Api Error: Invalid request - The release created has notes in language en-US with length 508, which is too long (max: 500)." />
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="43.975952">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.51.1"
|
||||
version_number: "1.52.1"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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!]);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ^
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
44
mobile/lib/modules/home/ui/user_circle_avatar.dart
Normal file
44
mobile/lib/modules/home/ui/user_circle_avatar.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
15
mobile/lib/modules/search/models/curated_content.dart
Normal file
15
mobile/lib/modules/search/models/curated_content.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
*/
|
||||
});
|
||||
@@ -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();
|
||||
*/
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
66
mobile/lib/modules/search/ui/curated_row.dart
Normal file
66
mobile/lib/modules/search/ui/curated_row.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/modules/search/ui/explore_grid.dart
Normal file
55
mobile/lib/modules/search/ui/explore_grid.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
31
mobile/lib/modules/search/ui/search_result_grid.dart
Normal file
31
mobile/lib/modules/search/ui/search_result_grid.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
35
mobile/lib/modules/search/views/all_motion_videos_page.dart
Normal file
35
mobile/lib/modules/search/views/all_motion_videos_page.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
mobile/lib/modules/search/views/all_videos_page.dart
Normal file
35
mobile/lib/modules/search/views/all_videos_page.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
mobile/lib/modules/search/views/curated_location_page.dart
Normal file
52
mobile/lib/modules/search/views/curated_location_page.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/modules/search/views/curated_object_page.dart
Normal file
55
mobile/lib/modules/search/views/curated_object_page.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
mobile/lib/modules/search/views/recently_added_page.dart
Normal file
35
mobile/lib/modules/search/views/recently_added_page.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
48
mobile/lib/shared/models/logger_message.model.dart
Normal file
48
mobile/lib/shared/models/logger_message.model.dart
Normal 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)];
|
||||
}
|
||||
1092
mobile/lib/shared/models/logger_message.model.g.dart
Normal file
1092
mobile/lib/shared/models/logger_message.model.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -169,7 +169,10 @@ class TabControllerPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
body: body,
|
||||
body: HeroControllerScope(
|
||||
controller: HeroController(),
|
||||
child: body,
|
||||
),
|
||||
bottomNavigationBar: multiselectEnabled ? null : bottom,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
14
mobile/lib/utils/db.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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"};
|
||||
}
|
||||
|
||||
@@ -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}';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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.52.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
|
||||
2
mobile/openapi/doc/AlbumApi.md
generated
2
mobile/openapi/doc/AlbumApi.md
generated
@@ -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);
|
||||
|
||||
1
mobile/openapi/doc/JobCountsDto.md
generated
1
mobile/openapi/doc/JobCountsDto.md
generated
@@ -13,6 +13,7 @@ Name | Type | Description | Notes
|
||||
**failed** | **int** | |
|
||||
**delayed** | **int** | |
|
||||
**waiting** | **int** | |
|
||||
**paused** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
12
mobile/openapi/doc/ServerInfoApi.md
generated
12
mobile/openapi/doc/ServerInfoApi.md
generated
@@ -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
|
||||
|
||||
|
||||
2
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
2
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
@@ -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)
|
||||
|
||||
|
||||
24
mobile/openapi/doc/UserApi.md
generated
24
mobile/openapi/doc/UserApi.md
generated
@@ -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
|
||||
|
||||
|
||||
3
mobile/openapi/lib/model/job_command.dart
generated
3
mobile/openapi/lib/model/job_command.dart
generated
@@ -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) {
|
||||
|
||||
14
mobile/openapi/lib/model/job_counts_dto.dart
generated
14
mobile/openapi/lib/model/job_counts_dto.dart
generated
@@ -18,6 +18,7 @@ class JobCountsDto {
|
||||
required this.failed,
|
||||
required this.delayed,
|
||||
required this.waiting,
|
||||
required this.paused,
|
||||
});
|
||||
|
||||
int active;
|
||||
@@ -30,13 +31,16 @@ class JobCountsDto {
|
||||
|
||||
int waiting;
|
||||
|
||||
int paused;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is JobCountsDto &&
|
||||
other.active == active &&
|
||||
other.completed == completed &&
|
||||
other.failed == failed &&
|
||||
other.delayed == delayed &&
|
||||
other.waiting == waiting;
|
||||
other.waiting == waiting &&
|
||||
other.paused == paused;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -45,10 +49,11 @@ class JobCountsDto {
|
||||
(completed.hashCode) +
|
||||
(failed.hashCode) +
|
||||
(delayed.hashCode) +
|
||||
(waiting.hashCode);
|
||||
(waiting.hashCode) +
|
||||
(paused.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'JobCountsDto[active=$active, completed=$completed, failed=$failed, delayed=$delayed, waiting=$waiting]';
|
||||
String toString() => 'JobCountsDto[active=$active, completed=$completed, failed=$failed, delayed=$delayed, waiting=$waiting, paused=$paused]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -57,6 +62,7 @@ class JobCountsDto {
|
||||
json[r'failed'] = this.failed;
|
||||
json[r'delayed'] = this.delayed;
|
||||
json[r'waiting'] = this.waiting;
|
||||
json[r'paused'] = this.paused;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -84,6 +90,7 @@ class JobCountsDto {
|
||||
failed: mapValueOfType<int>(json, r'failed')!,
|
||||
delayed: mapValueOfType<int>(json, r'delayed')!,
|
||||
waiting: mapValueOfType<int>(json, r'waiting')!,
|
||||
paused: mapValueOfType<int>(json, r'paused')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -138,6 +145,7 @@ class JobCountsDto {
|
||||
'failed',
|
||||
'delayed',
|
||||
'waiting',
|
||||
'paused',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
5
mobile/openapi/test/job_counts_dto_test.dart
generated
5
mobile/openapi/test/job_counts_dto_test.dart
generated
@@ -41,6 +41,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int paused
|
||||
test('to test the property `paused`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.51.1+74
|
||||
version: 1.52.1+75
|
||||
isar_version: &isar_version 3.0.5
|
||||
|
||||
environment:
|
||||
|
||||
@@ -20,6 +20,7 @@ void main() {
|
||||
fileModifiedAt: date,
|
||||
updatedAt: date,
|
||||
durationInSeconds: 0,
|
||||
type: AssetType.image,
|
||||
fileName: '',
|
||||
isFavorite: false,
|
||||
isLocal: false,
|
||||
|
||||
41
mobile/test/async_mutex_test.dart
Normal file
41
mobile/test/async_mutex_test.dart
Normal 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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ Asset _getTestAsset(int id, bool favorite) {
|
||||
updatedAt: DateTime.now(),
|
||||
isLocal: false,
|
||||
durationInSeconds: 0,
|
||||
type: AssetType.image,
|
||||
fileName: '',
|
||||
isFavorite: favorite,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
mobile/test/sync_service_test.dart
Normal file
143
mobile/test/sync_service_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,14 @@ map $http_upgrade $connection_upgrade {
|
||||
'' close;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_proto $forwarded_protocol {
|
||||
default $scheme;
|
||||
|
||||
# Only allow the values 'http' and 'https' for the X-Forwarded-Proto header.
|
||||
http http;
|
||||
https https;
|
||||
}
|
||||
|
||||
upstream server {
|
||||
server ${IMMICH_SERVER_HOST};
|
||||
keepalive 2;
|
||||
@@ -43,13 +51,12 @@ server {
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
rewrite /api/(.*) /$1 break;
|
||||
|
||||
@@ -64,13 +71,12 @@ server {
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_pass ${IMMICH_WEB_SCHEME}web;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user