diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh
index e1e81a9b52..544674e169 100755
--- a/.devcontainer/server/container-common.sh
+++ b/.devcontainer/server/container-common.sh
@@ -74,7 +74,7 @@ install_dependencies() {
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
- run_cmd make setup-dev
+ run_cmd make setup-web-dev setup-server-dev
)
log ""
}
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index a048536b2f..ca24e33b52 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -122,17 +122,17 @@ jobs:
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
if [[ $IS_MAIN == 'true' ]]; then
- flutter build apk --release
- flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
+ flutter build apk --release --flavor production
+ flutter build apk --release --flavor production --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
- flutter build apk --debug --split-per-abi --target-platform android-arm64
+ flutter build apk --debug --flavor production --split-per-abi --target-platform android-arm64
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-apk-signed
- path: mobile/build/app/outputs/flutter-apk/*.apk
+ path: mobile/build/app/outputs/flutter-apk/**/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
diff --git a/.github/workflows/org-checks.yml b/.github/workflows/org-checks.yml
index 55c5dfad5b..9781dc3b83 100644
--- a/.github/workflows/org-checks.yml
+++ b/.github/workflows/org-checks.yml
@@ -2,6 +2,7 @@ name: Org Checks
on:
pull_request_review:
+ pull_request:
jobs:
check-approvals:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index ed3da9f667..8682376cda 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -18,6 +18,25 @@
"name": "Immich Workers",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
+ },
+ {
+ "name": "Flavor - Production",
+ "request": "launch",
+ "type": "dart",
+ "codeLens": {
+ "for": [
+ "run-test",
+ "run-test-file",
+ "run-file",
+ "debug-test",
+ "debug-test-file",
+ "debug-file",
+ ],
+ "title": "${debugType}",
+ },
+ "args": [
+ "--flavor", "production"
+ ],
}
]
}
diff --git a/Makefile b/Makefile
index 207665d31c..815d1a153b 100644
--- a/Makefile
+++ b/Makefile
@@ -89,7 +89,7 @@ test-medium-dev:
docker exec -it immich_server /bin/sh -c "npm run test:medium"
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
-install-all: $(foreach M,$(MODULES),install-$M) ;
+install-all: $(foreach M,$(MODULES),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
@@ -106,4 +106,5 @@ clean:
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
-setup-dev: install-server install-sdk build-sdk install-web
\ No newline at end of file
+setup-server-dev: install-server
+setup-web-dev: install-sdk build-sdk install-web
diff --git a/cli/package-lock.json b/cli/package-lock.json
index 0bb42db17c..d5884841cd 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -42,7 +42,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
- "vite": "^6.0.0",
+ "vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
@@ -3466,9 +3466,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -3486,7 +3486,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -4196,24 +4196,24 @@
}
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
+ "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
- "fdir": "^6.4.4",
+ "fdir": "^6.4.6",
"picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
+ "postcss": "^8.5.6",
+ "rollup": "^4.40.0",
+ "tinyglobby": "^0.2.14"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -4222,14 +4222,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
- "less": "*",
+ "less": "^4.0.0",
"lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -4314,9 +4314,9 @@
}
},
"node_modules/vite/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.4.6",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+ "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
diff --git a/cli/package.json b/cli/package.json
index ca4ada2f65..e1f49475c0 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -36,7 +36,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
- "vite": "^6.0.0",
+ "vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
diff --git a/docs/docs/administration/img/admin-nightly-tasks.webp b/docs/docs/administration/img/admin-nightly-tasks.webp
new file mode 100644
index 0000000000..b3d8f13cb6
Binary files /dev/null and b/docs/docs/administration/img/admin-nightly-tasks.webp differ
diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md
index 75f97de982..4634151b9a 100644
--- a/docs/docs/administration/jobs-workers.md
+++ b/docs/docs/administration/jobs-workers.md
@@ -46,6 +46,12 @@ services:
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
-Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
-
+
+Additionally, some jobs (such as memories generation) run on a schedule, which is every night at midnight by default. To change when they run or enable/disable a job navigate to System Settings -> [Nightly Tasks Settings](https://my.immich.app/admin/system-settings?isOpen=nightly-tasks).
+
+
+
+:::note
+Some jobs ([External Libraries](/docs/features/libraries) scanning, Database Dump) are configured in their own sections in System Settings.
+:::
diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md
index 2ac917f930..3ad1679423 100644
--- a/docs/docs/guides/external-library.md
+++ b/docs/docs/guides/external-library.md
@@ -41,7 +41,7 @@ In the Immich web UI:
- Click Add path
-- Enter **/usr/src/app/external** as the path and click Add
+- Enter **/home/user/photos1** as the path and click Add
- Save the new path
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 2987223fe0..781d9c28a0 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -14,6 +14,7 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
+ "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.15.33",
"@types/oidc-provider": "^9.0.0",
diff --git a/e2e/package.json b/e2e/package.json
index 4669ca0a1a..24b97bb4b7 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -24,6 +24,7 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
+ "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.15.33",
"@types/oidc-provider": "^9.0.0",
diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts
index 70c32313f1..9ce9b4b916 100644
--- a/e2e/src/api/specs/activity.e2e-spec.ts
+++ b/e2e/src/api/specs/activity.e2e-spec.ts
@@ -7,6 +7,7 @@ import {
ReactionType,
createActivity as create,
createAlbum,
+ removeAssetFromAlbum,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -342,5 +343,36 @@ describe('/activities', () => {
expect(status).toBe(204);
});
+
+ it('should return empty list when asset is removed', async () => {
+ const album3 = await createAlbum(
+ {
+ createAlbumDto: {
+ albumName: 'Album 3',
+ assetIds: [asset.id],
+ },
+ },
+ { headers: asBearerAuth(admin.accessToken) },
+ );
+
+ await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like });
+
+ await removeAssetFromAlbum(
+ {
+ id: album3.id,
+ bulkIdsDto: {
+ ids: [asset.id],
+ },
+ },
+ { headers: asBearerAuth(admin.accessToken) },
+ );
+
+ const { status, body } = await request(app)
+ .get('/activities')
+ .query({ albumId: album.id })
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+ expect(status).toEqual(200);
+ expect(body).toEqual([]);
+ });
});
});
diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts
index ad03571869..28d134a664 100644
--- a/e2e/src/api/specs/api-key.e2e-spec.ts
+++ b/e2e/src/api/specs/api-key.e2e-spec.ts
@@ -20,7 +20,7 @@ describe('/api-keys', () => {
});
beforeEach(async () => {
- await utils.resetDatabase(['api_keys']);
+ await utils.resetDatabase(['api_key']);
});
describe('POST /api-keys', () => {
diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts
index 060163d7c9..1bd7bdc489 100644
--- a/e2e/src/api/specs/system-config.e2e-spec.ts
+++ b/e2e/src/api/specs/system-config.e2e-spec.ts
@@ -15,12 +15,6 @@ describe('/system-config', () => {
});
describe('PUT /system-config', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(app).put('/system-config');
- expect(status).toBe(401);
- expect(body).toEqual(errorDto.unauthorized);
- });
-
it('should always return the new config', async () => {
const config = await getSystemConfig(admin.accessToken);
diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts
index a4cbc99ed3..7b645f8bd4 100644
--- a/e2e/src/api/specs/tag.e2e-spec.ts
+++ b/e2e/src/api/specs/tag.e2e-spec.ts
@@ -37,7 +37,7 @@ describe('/tags', () => {
beforeEach(async () => {
// tagging assets eventually triggers metadata extraction which can impact other tests
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
- await utils.resetDatabase(['tags']);
+ await utils.resetDatabase(['tag']);
});
describe('POST /tags', () => {
diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts
index c901a299ab..8249b9b360 100644
--- a/e2e/src/cli/specs/upload.e2e-spec.ts
+++ b/e2e/src/cli/specs/upload.e2e-spec.ts
@@ -97,7 +97,7 @@ describe(`immich upload`, () => {
});
beforeEach(async () => {
- await utils.resetDatabase(['assets', 'albums']);
+ await utils.resetDatabase(['asset', 'album']);
});
describe(`immich upload /path/to/file.jpg`, () => {
diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts
index bb6d17a248..b14aedf895 100644
--- a/e2e/src/responses.ts
+++ b/e2e/src/responses.ts
@@ -116,6 +116,7 @@ export const deviceDto = {
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
+ isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
},
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index cf9eafbf23..3fcc4ab552 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -154,19 +154,19 @@ export const utils = {
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
- 'asset_stack',
- 'libraries',
- 'shared_links',
+ 'stack',
+ 'library',
+ 'shared_link',
'person',
- 'albums',
- 'assets',
- 'asset_faces',
+ 'album',
+ 'asset',
+ 'asset_face',
'activity',
- 'api_keys',
- 'sessions',
- 'users',
+ 'api_key',
+ 'session',
+ 'user',
'system_metadata',
- 'tags',
+ 'tag',
];
const sql: string[] = [];
@@ -175,7 +175,7 @@ export const utils = {
if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
- sql.push(`DELETE FROM ${table} CASCADE;`);
+ sql.push(`DELETE FROM "${table}" CASCADE;`);
}
}
@@ -451,7 +451,7 @@ export const utils = {
return;
}
- await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
+ await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
},
setPersonThumbnail: async (personId: string) => {
diff --git a/i18n/en.json b/i18n/en.json
index 64e9fce58b..7f7dae833d 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -166,6 +166,20 @@
"metadata_settings_description": "Manage metadata settings",
"migration_job": "Migration",
"migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
+ "nightly_tasks_cluster_faces_setting_description": "Run facial recognition on newly detected faces",
+ "nightly_tasks_cluster_new_faces_setting": "Cluster new faces",
+ "nightly_tasks_database_cleanup_setting": "Database cleanup tasks",
+ "nightly_tasks_database_cleanup_setting_description": "Clean up old, expired data from the database",
+ "nightly_tasks_generate_memories_setting": "Generate memories",
+ "nightly_tasks_generate_memories_setting_description": "Create new memories from assets",
+ "nightly_tasks_missing_thumbnails_setting": "Generate missing thumbnails",
+ "nightly_tasks_missing_thumbnails_setting_description": "Queue assets without thumbnails for thumbnail generation",
+ "nightly_tasks_settings": "Nightly Tasks Settings",
+ "nightly_tasks_settings_description": "Manage nightly tasks",
+ "nightly_tasks_start_time_setting": "Start time",
+ "nightly_tasks_start_time_setting_description": "The time at which the server starts running the nightly tasks",
+ "nightly_tasks_sync_quota_usage_setting": "Sync quota usage",
+ "nightly_tasks_sync_quota_usage_setting_description": "Update user storage quota, based on current usage",
"no_paths_added": "No paths added",
"no_pattern_added": "No pattern added",
"note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
@@ -1502,6 +1516,7 @@
"remove_custom_date_range": "Remove custom date range",
"remove_deleted_assets": "Remove Deleted Assets",
"remove_from_album": "Remove from album",
+ "remove_from_album_action_prompt": "{count} removed from the album",
"remove_from_favorites": "Remove from favorites",
"remove_from_lock_folder_action_prompt": "{count} removed from the locked folder",
"remove_from_locked_folder": "Remove from locked folder",
diff --git a/mobile/.fvmrc b/mobile/.fvmrc
index b987073ac6..7d2a608dfa 100644
--- a/mobile/.fvmrc
+++ b/mobile/.fvmrc
@@ -1,3 +1,3 @@
{
- "flutter": "3.29.3"
+ "flutter": "3.32.6"
}
\ No newline at end of file
diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json
index 9c5244f098..c1b3fc9ce3 100644
--- a/mobile/.vscode/settings.json
+++ b/mobile/.vscode/settings.json
@@ -1,5 +1,5 @@
{
- "dart.flutterSdkPath": ".fvm/versions/3.29.3",
+ "dart.flutterSdkPath": ".fvm/versions/3.32.6",
"search.exclude": {
"**/.fvm": true
},
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 0d07228252..870e424461 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -66,6 +66,20 @@ android {
}
}
+ flavorDimensions "default"
+ productFlavors {
+ production {
+ dimension "default"
+ applicationId "app.alextran.immich"
+ }
+
+ beta {
+ dimension "default"
+ applicationId "app.alextran.immich.beta"
+ versionNameSuffix "-BETA"
+ }
+ }
+
buildTypes {
debug {
applicationIdSuffix '.debug'
diff --git a/mobile/android/app/src/beta/AndroidManifest.xml b/mobile/android/app/src/beta/AndroidManifest.xml
new file mode 100644
index 0000000000..d4f7b5bc80
--- /dev/null
+++ b/mobile/android/app/src/beta/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 6a96aa68e4..cf3b7ee719 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -100,24 +100,24 @@
-
-
-
+
+
+
-
+
-
-
-
-
+
+
+
+
diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock
index e05cbb3db3..0e4b08be87 100644
--- a/mobile/immich_lint/pubspec.lock
+++ b/mobile/immich_lint/pubspec.lock
@@ -5,34 +5,34 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
- sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
+ sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f
url: "https://pub.dev"
source: hosted
- version: "80.0.0"
+ version: "82.0.0"
analyzer:
dependency: "direct main"
description:
name: analyzer
- sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
+ sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0"
url: "https://pub.dev"
source: hosted
- version: "7.3.0"
+ version: "7.4.5"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
- sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
+ sha256: ee188b6df6c85f1441497c7171c84f1392affadc0384f71089cb10a3bc508cef
url: "https://pub.dev"
source: hosted
- version: "0.13.0"
+ version: "0.13.1"
args:
dependency: transitive
description:
name: args
- sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
+ sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
- version: "2.6.0"
+ version: "2.7.0"
async:
dependency: transitive
description:
@@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: checked_yaml
- sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
+ sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
- version: "2.0.3"
+ version: "2.0.4"
ci:
dependency: transitive
description:
@@ -125,10 +125,10 @@ packages:
dependency: transitive
description:
name: custom_lint_visitor
- sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
+ sha256: cba5b6d7a6217312472bf4468cdf68c949488aed7ffb0eab792cd0b6c435054d
url: "https://pub.dev"
source: hosted
- version: "1.0.0+7.3.0"
+ version: "1.0.0+7.4.5"
dart_style:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: transitive
description:
name: freezed_annotation
- sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
+ sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
- version: "3.0.0"
+ version: "3.1.0"
glob:
dependency: "direct main"
description:
@@ -213,18 +213,18 @@ packages:
dependency: transitive
description:
name: meta
- sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
- version: "1.16.0"
+ version: "1.17.0"
package_config:
dependency: transitive
description:
name: package_config
- sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67"
+ sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
- version: "2.1.1"
+ version: "2.2.0"
path:
dependency: transitive
description:
@@ -317,10 +317,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
+ sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
- version: "0.7.4"
+ version: "0.7.6"
typed_data:
dependency: transitive
description:
@@ -341,18 +341,18 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
+ sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
- version: "15.0.0"
+ version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
- sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
+ sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
url: "https://pub.dev"
source: hosted
- version: "1.1.1"
+ version: "1.1.2"
yaml:
dependency: transitive
description:
@@ -362,4 +362,4 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.8.0-0 <4.0.0"
+ dart: ">=3.8.0 <4.0.0"
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index fb0908e8b6..1a39f98db3 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 54;
+ objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -117,8 +117,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
- exceptions = (
- );
path = Sync;
sourceTree = "";
};
@@ -473,10 +471,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
+ inputPaths = (
+ );
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
+ outputPaths = (
+ );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -505,10 +507,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
+ inputPaths = (
+ );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
+ outputPaths = (
+ );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
diff --git a/mobile/ios/WidgetExtension/Info.plist b/mobile/ios/WidgetExtension/Info.plist
index 0f118fb75e..d4e598ee31 100644
--- a/mobile/ios/WidgetExtension/Info.plist
+++ b/mobile/ios/WidgetExtension/Info.plist
@@ -2,6 +2,11 @@
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
NSExtension
NSExtensionPointIdentifier
diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart
index 29f75f29ef..7cafca9116 100644
--- a/mobile/lib/domain/models/album/album.model.dart
+++ b/mobile/lib/domain/models/album/album.model.dart
@@ -11,7 +11,7 @@ enum AlbumUserRole {
}
// Model for an album stored in the server
-class Album {
+class RemoteAlbum {
final String id;
final String name;
final String ownerId;
@@ -24,7 +24,7 @@ class Album {
final int assetCount;
final String ownerName;
- const Album({
+ const RemoteAlbum({
required this.id,
required this.name,
required this.ownerId,
@@ -57,7 +57,7 @@ class Album {
@override
bool operator ==(Object other) {
- if (other is! Album) return false;
+ if (other is! RemoteAlbum) return false;
if (identical(this, other)) return true;
return id == other.id &&
name == other.name &&
diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart
index f3106470d2..ae9e8b5336 100644
--- a/mobile/lib/domain/services/remote_album.service.dart
+++ b/mobile/lib/domain/services/remote_album.service.dart
@@ -1,33 +1,35 @@
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
+import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
+ final DriftAlbumApiRepository _albumApiRepository;
- const RemoteAlbumService(this._repository);
+ const RemoteAlbumService(this._repository, this._albumApiRepository);
- Future> getAll() {
+ Future> getAll() {
return _repository.getAll();
}
- List sortAlbums(
- List albums,
+ List sortAlbums(
+ List albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) {
return sortMode.sortFn(albums, isReverse);
}
- List searchAlbums(
- List albums,
+ List searchAlbums(
+ List albums,
String query,
String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all,
]) {
final lowerQuery = query.toLowerCase();
- List filtered = albums;
+ List filtered = albums;
// Apply text search filter
if (query.isNotEmpty) {
@@ -57,4 +59,20 @@ class RemoteAlbumService {
return filtered;
}
+
+ Future createAlbum({
+ required String title,
+ required List assetIds,
+ String? description,
+ }) async {
+ final album = await _albumApiRepository.createDriftAlbum(
+ title,
+ description: description,
+ assetIds: assetIds,
+ );
+
+ await _repository.create(album, assetIds);
+
+ return album;
+ }
}
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index ee0ec6c44f..29066195f2 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
+import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -23,7 +24,12 @@ class SyncStreamService {
bool get isCancelled => _cancelChecker?.call() ?? false;
- Future sync() => _syncApiRepository.streamChanges(_handleEvents);
+ Future sync() {
+ _logger.info("Remote sync request for userr");
+ DLog.log("Remote sync request for user");
+ // Start the sync stream and handle events
+ return _syncApiRepository.streamChanges(_handleEvents);
+ }
Future _handleEvents(List events, Function() abort) async {
List items = [];
diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart
index b3d6b51402..8204572547 100644
--- a/mobile/lib/domain/services/timeline.service.dart
+++ b/mobile/lib/domain/services/timeline.service.dart
@@ -18,6 +18,11 @@ typedef TimelineAssetSource = Future> Function(
typedef TimelineBucketSource = Stream> Function();
+typedef TimelineQuery = ({
+ TimelineAssetSource assetSource,
+ TimelineBucketSource bucketSource,
+});
+
class TimelineFactory {
final DriftTimelineRepository _timelineRepository;
final SettingsService _settingsService;
@@ -31,78 +36,32 @@ class TimelineFactory {
GroupAssetsBy get groupBy =>
GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)];
- TimelineService main(List timelineUsers) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getMainBucketAssets(timelineUsers, offset: offset, count: count),
- bucketSource: () => _timelineRepository.watchMainBucket(
- timelineUsers,
- groupBy: groupBy,
- ),
- );
+ TimelineService main(List timelineUsers) =>
+ TimelineService(_timelineRepository.main(timelineUsers, groupBy));
- TimelineService localAlbum({required String albumId}) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getLocalAlbumBucketAssets(albumId, offset: offset, count: count),
- bucketSource: () => _timelineRepository.watchLocalAlbumBucket(
- albumId,
- groupBy: groupBy,
- ),
- );
+ TimelineService localAlbum({required String albumId}) =>
+ TimelineService(_timelineRepository.localAlbum(albumId, groupBy));
- TimelineService remoteAlbum({required String albumId}) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getRemoteAlbumBucketAssets(albumId, offset: offset, count: count),
- bucketSource: () => _timelineRepository.watchRemoteAlbumBucket(
- albumId,
- groupBy: groupBy,
- ),
- );
+ TimelineService remoteAlbum({required String albumId}) =>
+ TimelineService(_timelineRepository.remoteAlbum(albumId, groupBy));
- TimelineService remoteAssets(String ownerId) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getRemoteBucketAssets(ownerId, offset: offset, count: count),
- bucketSource: () => _timelineRepository.watchRemoteBucket(
- ownerId,
- groupBy: GroupAssetsBy.month,
- ),
- );
+ TimelineService remoteAssets(String userId) =>
+ TimelineService(_timelineRepository.remote(userId, groupBy));
- TimelineService favorite(String userId) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getFavoriteBucketAssets(userId, offset: offset, count: count),
- bucketSource: () =>
- _timelineRepository.watchFavoriteBucket(userId, groupBy: groupBy),
- );
+ TimelineService favorite(String userId) =>
+ TimelineService(_timelineRepository.favorite(userId, groupBy));
- TimelineService trash(String userId) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getTrashBucketAssets(userId, offset: offset, count: count),
- bucketSource: () =>
- _timelineRepository.watchTrashBucket(userId, groupBy: groupBy),
- );
+ TimelineService trash(String userId) =>
+ TimelineService(_timelineRepository.trash(userId, groupBy));
- TimelineService archive(String userId) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getArchiveBucketAssets(userId, offset: offset, count: count),
- bucketSource: () =>
- _timelineRepository.watchArchiveBucket(userId, groupBy: groupBy),
- );
+ TimelineService archive(String userId) =>
+ TimelineService(_timelineRepository.archived(userId, groupBy));
- TimelineService lockedFolder(String userId) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getLockedFolderBucketAssets(userId, offset: offset, count: count),
- bucketSource: () => _timelineRepository.watchLockedFolderBucket(
- userId,
- groupBy: groupBy,
- ),
- );
+ TimelineService lockedFolder(String userId) =>
+ TimelineService(_timelineRepository.locked(userId, groupBy));
- TimelineService video(String userId) => TimelineService(
- assetSource: (offset, count) => _timelineRepository
- .getVideoBucketAssets(userId, offset: offset, count: count),
- bucketSource: () =>
- _timelineRepository.watchVideoBucket(userId, groupBy: groupBy),
- );
+ TimelineService video(String userId) =>
+ TimelineService(_timelineRepository.video(userId, groupBy));
}
class TimelineService {
@@ -116,7 +75,13 @@ class TimelineService {
int _totalAssets = 0;
int get totalAssets => _totalAssets;
- TimelineService({
+ TimelineService(TimelineQuery query)
+ : this._(
+ assetSource: query.assetSource,
+ bucketSource: query.bucketSource,
+ );
+
+ TimelineService._({
required TimelineAssetSource assetSource,
required TimelineBucketSource bucketSource,
}) : _assetSource = assetSource,
@@ -210,6 +175,9 @@ class TimelineService {
Future preCacheAssets(int index) =>
_mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
+ BaseAsset getRandomAsset() =>
+ _buffer.elementAt(math.Random().nextInt(_buffer.length));
+
BaseAsset getAsset(int index) {
if (!hasRange(index, 1)) {
throw RangeError(
diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart
index 5bbd73163a..2752d0b77a 100644
--- a/mobile/lib/extensions/scroll_extensions.dart
+++ b/mobile/lib/extensions/scroll_extensions.dart
@@ -11,9 +11,9 @@ class FastScrollPhysics extends ScrollPhysics {
@override
SpringDescription get spring => const SpringDescription(
- mass: 40,
- stiffness: 100,
- damping: 1,
+ mass: 1,
+ stiffness: 402.49984375,
+ damping: 40,
);
}
@@ -31,8 +31,8 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
// can briefly be seen and cause a flicker effect if the video begins to initialize
// before the animation finishes - probably a bug in PhotoViewGallery's animation handling
// Making the animation faster is not just stylistic, but also helps to avoid this flicker
- mass: 80,
- stiffness: 100,
- damping: 1,
+ mass: 1,
+ stiffness: 1601.2499609375,
+ damping: 80,
);
}
diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart
index b1b73b4cdc..b77184bce0 100644
--- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart
+++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart
@@ -1,15 +1,18 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
-enum SortRemoteAlbumsBy { id }
+enum SortRemoteAlbumsBy { id, updatedAt }
class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftRemoteAlbumRepository(this._db) : super(_db);
- Future> getAll({Set sortBy = const {}}) {
+ Future> getAll({
+ Set sortBy = const {SortRemoteAlbumsBy.updatedAt},
+ }) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
final query = _db.remoteAlbumEntity.select().join([
@@ -41,6 +44,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
orderings.add(
switch (sort) {
SortRemoteAlbumsBy.id => OrderingTerm.asc(_db.remoteAlbumEntity.id),
+ SortRemoteAlbumsBy.updatedAt =>
+ OrderingTerm.desc(_db.remoteAlbumEntity.updatedAt),
},
);
}
@@ -56,11 +61,54 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
)
.get();
}
+
+ Future create(
+ RemoteAlbum album,
+ List assetIds,
+ ) async {
+ await _db.transaction(() async {
+ final entity = RemoteAlbumEntityCompanion(
+ id: Value(album.id),
+ name: Value(album.name),
+ ownerId: Value(album.ownerId),
+ createdAt: Value(album.createdAt),
+ updatedAt: Value(album.updatedAt),
+ description: Value(album.description),
+ thumbnailAssetId: Value(album.thumbnailAssetId),
+ isActivityEnabled: Value(album.isActivityEnabled),
+ order: Value(album.order),
+ );
+
+ await _db.remoteAlbumEntity.insertOne(entity);
+
+ if (assetIds.isNotEmpty) {
+ final albumAssets = assetIds.map(
+ (assetId) => RemoteAlbumAssetEntityCompanion(
+ albumId: Value(album.id),
+ assetId: Value(assetId),
+ ),
+ );
+
+ await _db.batch((batch) {
+ batch.insertAll(
+ _db.remoteAlbumAssetEntity,
+ albumAssets,
+ );
+ });
+ }
+ });
+ }
+
+ Future removeAssets(String albumId, List assetIds) {
+ return _db.remoteAlbumAssetEntity.deleteWhere(
+ (tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds),
+ );
+ }
}
extension on RemoteAlbumEntityData {
- Album toDto({int assetCount = 0, required String ownerName}) {
- return Album(
+ RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
+ return RemoteAlbum(
id: id,
name: name,
ownerId: ownerId,
diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart
index 569df0aed8..76ad9bad8f 100644
--- a/mobile/lib/infrastructure/repositories/timeline.repository.dart
+++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart
@@ -5,8 +5,10 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
+import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
+import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:stream_transform/stream_transform.dart';
@@ -30,19 +32,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.map((users) => users..add(userId));
}
- List _generateBuckets(int count) {
- final numBuckets = (count / kTimelineNoneSegmentSize).floor();
- final buckets = List.generate(
- numBuckets,
- (_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
- );
- if (count % kTimelineNoneSegmentSize != 0) {
- buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
- }
- return buckets;
- }
+ TimelineQuery main(List userIds, GroupAssetsBy groupBy) => (
+ bucketSource: () => _watchMainBucket(
+ userIds,
+ groupBy: groupBy,
+ ),
+ assetSource: (offset, count) => _getMainBucketAssets(
+ userIds,
+ offset: offset,
+ count: count,
+ ),
+ );
- Stream> watchMainBucket(
+ Stream> _watchMainBucket(
List userIds, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
@@ -62,7 +64,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.throttle(const Duration(seconds: 3), trailing: true);
}
- Future> getMainBucketAssets(
+ Future> _getMainBucketAssets(
List userIds, {
required int offset,
required int count,
@@ -70,42 +72,53 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return _db.mergedAssetDrift
.mergedAsset(userIds, limit: Limit(count, offset))
.map(
- (row) {
- return row.remoteId != null && row.ownerId != null
- ? RemoteAsset(
- id: row.remoteId!,
- localId: row.localId,
- name: row.name,
- ownerId: row.ownerId!,
- checksum: row.checksum,
- type: row.type,
- createdAt: row.createdAt,
- updatedAt: row.updatedAt,
- thumbHash: row.thumbHash,
- width: row.width,
- height: row.height,
- isFavorite: row.isFavorite,
- durationInSeconds: row.durationInSeconds,
- )
- : LocalAsset(
- id: row.localId!,
- remoteId: row.remoteId,
- name: row.name,
- checksum: row.checksum,
- type: row.type,
- createdAt: row.createdAt,
- updatedAt: row.updatedAt,
- width: row.width,
- height: row.height,
- isFavorite: row.isFavorite,
- durationInSeconds: row.durationInSeconds,
- orientation: row.orientation,
- );
- },
- ).get();
+ (row) => row.remoteId != null && row.ownerId != null
+ ? RemoteAsset(
+ id: row.remoteId!,
+ localId: row.localId,
+ name: row.name,
+ ownerId: row.ownerId!,
+ checksum: row.checksum,
+ type: row.type,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ thumbHash: row.thumbHash,
+ width: row.width,
+ height: row.height,
+ isFavorite: row.isFavorite,
+ durationInSeconds: row.durationInSeconds,
+ )
+ : LocalAsset(
+ id: row.localId!,
+ remoteId: row.remoteId,
+ name: row.name,
+ checksum: row.checksum,
+ type: row.type,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ width: row.width,
+ height: row.height,
+ isFavorite: row.isFavorite,
+ durationInSeconds: row.durationInSeconds,
+ orientation: row.orientation,
+ ),
+ )
+ .get();
}
- Stream> watchLocalAlbumBucket(
+ TimelineQuery localAlbum(String albumId, GroupAssetsBy groupBy) => (
+ bucketSource: () => _watchLocalAlbumBucket(
+ albumId,
+ groupBy: groupBy,
+ ),
+ assetSource: (offset, count) => _getLocalAlbumBucketAssets(
+ albumId,
+ offset: offset,
+ count: count,
+ ),
+ );
+
+ Stream> _watchLocalAlbumBucket(
String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
@@ -119,15 +132,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy);
- final query = _db.localAssetEntity.selectOnly()
+ final query = _db.localAssetEntity.selectOnly().join([
+ innerJoin(
+ _db.localAlbumAssetEntity,
+ _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
+ useColumns: false,
+ ),
+ ])
..addColumns([assetCountExp, dateExp])
- ..join([
- innerJoin(
- _db.localAlbumAssetEntity,
- _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
- useColumns: false,
- ),
- ])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
@@ -139,7 +151,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
- Future> getLocalAlbumBucketAssets(
+ Future> _getLocalAlbumBucketAssets(
String albumId, {
required int offset,
required int count,
@@ -156,12 +168,25 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset);
+
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
- Stream> watchRemoteAlbumBucket(
+ TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
+ bucketSource: () => _watchRemoteAlbumBucket(
+ albumId,
+ groupBy: groupBy,
+ ),
+ assetSource: (offset, count) => _getRemoteAlbumBucketAssets(
+ albumId,
+ offset: offset,
+ count: count,
+ ),
+ );
+
+ Stream> _watchRemoteAlbumBucket(
String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
@@ -199,7 +224,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
- Future> getRemoteAlbumBucketAssets(
+ Future> _getRemoteAlbumBucketAssets(
String albumId, {
required int offset,
required int count,
@@ -225,325 +250,101 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.get();
}
- Stream> watchRemoteBucket(
- String ownerId, {
- GroupAssetsBy groupBy = GroupAssetsBy.day,
- }) {
- if (groupBy == GroupAssetsBy.none) {
- return _db.remoteAssetEntity
- .count(
- where: (row) =>
- row.deletedAt.isNull() &
- row.visibility.equalsValue(AssetVisibility.timeline) &
- row.ownerId.equals(ownerId),
- )
- .map(_generateBuckets)
- .watchSingle();
- }
-
- final assetCountExp = _db.remoteAssetEntity.id.count();
- final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
-
- final query = _db.remoteAssetEntity.selectOnly()
- ..addColumns([assetCountExp, dateExp])
- ..where(
- _db.remoteAssetEntity.deletedAt.isNull() &
- _db.remoteAssetEntity.visibility
- .equalsValue(AssetVisibility.timeline) &
- _db.remoteAssetEntity.ownerId.equals(ownerId),
- )
- ..groupBy([dateExp])
- ..orderBy([OrderingTerm.desc(dateExp)]);
-
- return query.map((row) {
- final timeline = row.read(dateExp)!.dateFmt(groupBy);
- final assetCount = row.read(assetCountExp)!;
- return TimeBucket(date: timeline, assetCount: assetCount);
- }).watch();
- }
-
- Future> getRemoteBucketAssets(
- String ownerId, {
- required int offset,
- required int count,
- }) {
- final query = _db.remoteAssetEntity.select()
- ..where(
- (row) =>
+ TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
+ _remoteQueryBuilder(
+ filter: (row) =>
row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.timeline) &
row.ownerId.equals(ownerId),
- )
- ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
- ..limit(count, offset: offset);
+ groupBy: groupBy,
+ );
- return query.map((row) => row.toDto()).get();
- }
-
- Stream> watchFavoriteBucket(
- String userId, {
- GroupAssetsBy groupBy = GroupAssetsBy.day,
- }) {
- if (groupBy == GroupAssetsBy.none) {
- return _db.remoteAssetEntity
- .count(
- where: (row) =>
- row.deletedAt.isNull() &
- row.isFavorite.equals(true) &
- row.ownerId.equals(userId),
- )
- .map(_generateBuckets)
- .watchSingle();
- }
-
- final assetCountExp = _db.remoteAssetEntity.id.count();
- final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
-
- final query = _db.remoteAssetEntity.selectOnly()
- ..addColumns([assetCountExp, dateExp])
- ..where(
- _db.remoteAssetEntity.deletedAt.isNull() &
- _db.remoteAssetEntity.ownerId.equals(userId) &
- _db.remoteAssetEntity.isFavorite.equals(true),
- )
- ..groupBy([dateExp])
- ..orderBy([OrderingTerm.desc(dateExp)]);
-
- return query.map((row) {
- final timeline = row.read(dateExp)!.dateFmt(groupBy);
- final assetCount = row.read(assetCountExp)!;
- return TimeBucket(date: timeline, assetCount: assetCount);
- }).watch();
- }
-
- Future> getFavoriteBucketAssets(
- String userId, {
- required int offset,
- required int count,
- }) {
- final query = _db.remoteAssetEntity.select()
- ..where(
- (row) =>
+ TimelineQuery favorite(String userId, GroupAssetsBy groupBy) =>
+ _remoteQueryBuilder(
+ filter: (row) =>
row.deletedAt.isNull() &
row.isFavorite.equals(true) &
row.ownerId.equals(userId),
- )
- ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
- ..limit(count, offset: offset);
+ groupBy: groupBy,
+ );
- return query.map((row) => row.toDto()).get();
- }
+ TimelineQuery trash(String userId, GroupAssetsBy groupBy) =>
+ _remoteQueryBuilder(
+ filter: (row) => row.deletedAt.isNotNull() & row.ownerId.equals(userId),
+ groupBy: groupBy,
+ );
- Stream> watchTrashBucket(
- String userId, {
- GroupAssetsBy groupBy = GroupAssetsBy.day,
- }) {
- if (groupBy == GroupAssetsBy.none) {
- return _db.remoteAssetEntity
- .count(
- where: (row) =>
- row.deletedAt.isNotNull() & row.ownerId.equals(userId),
- )
- .map(_generateBuckets)
- .watchSingle();
- }
-
- final assetCountExp = _db.remoteAssetEntity.id.count();
- final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
-
- final query = _db.remoteAssetEntity.selectOnly()
- ..addColumns([assetCountExp, dateExp])
- ..where(
- _db.remoteAssetEntity.ownerId.equals(userId) &
- _db.remoteAssetEntity.deletedAt.isNotNull(),
- )
- ..groupBy([dateExp])
- ..orderBy([OrderingTerm.desc(dateExp)]);
-
- return query.map((row) {
- final timeline = row.read(dateExp)!.dateFmt(groupBy);
- final assetCount = row.read(assetCountExp)!;
- return TimeBucket(date: timeline, assetCount: assetCount);
- }).watch();
- }
-
- Future> getTrashBucketAssets(
- String userId, {
- required int offset,
- required int count,
- }) {
- final query = _db.remoteAssetEntity.select()
- ..where(
- (row) => row.deletedAt.isNotNull() & row.ownerId.equals(userId),
- )
- ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
- ..limit(count, offset: offset);
-
- return query.map((row) => row.toDto()).get();
- }
-
- Stream> watchArchiveBucket(
- String userId, {
- GroupAssetsBy groupBy = GroupAssetsBy.day,
- }) {
- if (groupBy == GroupAssetsBy.none) {
- return _db.remoteAssetEntity
- .count(
- where: (row) =>
- row.deletedAt.isNull() &
- row.visibility.equalsValue(AssetVisibility.archive) &
- row.ownerId.equals(userId),
- )
- .map(_generateBuckets)
- .watchSingle();
- }
-
- final assetCountExp = _db.remoteAssetEntity.id.count();
- final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
-
- final query = _db.remoteAssetEntity.selectOnly()
- ..addColumns([assetCountExp, dateExp])
- ..where(
- _db.remoteAssetEntity.deletedAt.isNull() &
- _db.remoteAssetEntity.ownerId.equals(userId) &
- _db.remoteAssetEntity.visibility
- .equalsValue(AssetVisibility.archive),
- )
- ..groupBy([dateExp])
- ..orderBy([OrderingTerm.desc(dateExp)]);
-
- return query.map((row) {
- final timeline = row.read(dateExp)!.dateFmt(groupBy);
- final assetCount = row.read(assetCountExp)!;
- return TimeBucket(date: timeline, assetCount: assetCount);
- }).watch();
- }
-
- Future> getArchiveBucketAssets(
- String userId, {
- required int offset,
- required int count,
- }) {
- final query = _db.remoteAssetEntity.select()
- ..where(
- (row) =>
+ TimelineQuery archived(String userId, GroupAssetsBy groupBy) =>
+ _remoteQueryBuilder(
+ filter: (row) =>
row.deletedAt.isNull() &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.archive),
- )
- ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
- ..limit(count, offset: offset);
+ groupBy: groupBy,
+ );
- return query.map((row) => row.toDto()).get();
- }
-
- Stream> watchLockedFolderBucket(
- String userId, {
- GroupAssetsBy groupBy = GroupAssetsBy.day,
- }) {
- if (groupBy == GroupAssetsBy.none) {
- return _db.remoteAssetEntity
- .count(
- where: (row) =>
- row.deletedAt.isNull() &
- row.visibility.equalsValue(AssetVisibility.locked) &
- row.ownerId.equals(userId),
- )
- .map(_generateBuckets)
- .watchSingle();
- }
-
- final assetCountExp = _db.remoteAssetEntity.id.count();
- final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
-
- final query = _db.remoteAssetEntity.selectOnly()
- ..addColumns([assetCountExp, dateExp])
- ..where(
- _db.remoteAssetEntity.deletedAt.isNull() &
- _db.remoteAssetEntity.ownerId.equals(userId) &
- _db.remoteAssetEntity.visibility
- .equalsValue(AssetVisibility.locked),
- )
- ..groupBy([dateExp])
- ..orderBy([OrderingTerm.desc(dateExp)]);
-
- return query.map((row) {
- final timeline = row.read(dateExp)!.dateFmt(groupBy);
- final assetCount = row.read(assetCountExp)!;
- return TimeBucket(date: timeline, assetCount: assetCount);
- }).watch();
- }
-
- Future> getLockedFolderBucketAssets(
- String userId, {
- required int offset,
- required int count,
- }) {
- final query = _db.remoteAssetEntity.select()
- ..where(
- (row) =>
+ TimelineQuery locked(String userId, GroupAssetsBy groupBy) =>
+ _remoteQueryBuilder(
+ filter: (row) =>
row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.locked) &
row.ownerId.equals(userId),
- )
- ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
- ..limit(count, offset: offset);
+ groupBy: groupBy,
+ );
- return query.map((row) => row.toDto()).get();
- }
-
- Stream> watchVideoBucket(
- String userId, {
- GroupAssetsBy groupBy = GroupAssetsBy.day,
- }) {
- if (groupBy == GroupAssetsBy.none) {
- return _db.remoteAssetEntity
- .count(
- where: (row) =>
- row.deletedAt.isNull() &
- row.type.equalsValue(AssetType.video) &
- row.visibility.equalsValue(AssetVisibility.timeline) &
- row.ownerId.equals(userId),
- )
- .map(_generateBuckets)
- .watchSingle();
- }
-
- final assetCountExp = _db.remoteAssetEntity.id.count();
- final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
-
- final query = _db.remoteAssetEntity.selectOnly()
- ..addColumns([assetCountExp, dateExp])
- ..where(
- _db.remoteAssetEntity.deletedAt.isNull() &
- _db.remoteAssetEntity.ownerId.equals(userId) &
- _db.remoteAssetEntity.type.equalsValue(AssetType.video) &
- _db.remoteAssetEntity.visibility
- .equalsValue(AssetVisibility.timeline),
- )
- ..groupBy([dateExp])
- ..orderBy([OrderingTerm.desc(dateExp)]);
-
- return query.map((row) {
- final timeline = row.read(dateExp)!.dateFmt(groupBy);
- final assetCount = row.read(assetCountExp)!;
- return TimeBucket(date: timeline, assetCount: assetCount);
- }).watch();
- }
-
- Future> getVideoBucketAssets(
- String userId, {
- required int offset,
- required int count,
- }) {
- final query = _db.remoteAssetEntity.select()
- ..where(
- (row) =>
+ TimelineQuery video(String userId, GroupAssetsBy groupBy) =>
+ _remoteQueryBuilder(
+ filter: (row) =>
row.deletedAt.isNull() &
row.type.equalsValue(AssetType.video) &
row.visibility.equalsValue(AssetVisibility.timeline) &
row.ownerId.equals(userId),
- )
+ groupBy: groupBy,
+ );
+
+ TimelineQuery _remoteQueryBuilder({
+ required Expression Function($RemoteAssetEntityTable row) filter,
+ GroupAssetsBy groupBy = GroupAssetsBy.day,
+ }) {
+ return (
+ bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy),
+ assetSource: (offset, count) =>
+ _getRemoteAssets(filter: filter, offset: offset, count: count),
+ );
+ }
+
+ Stream> _watchRemoteBucket({
+ required Expression Function($RemoteAssetEntityTable row) filter,
+ GroupAssetsBy groupBy = GroupAssetsBy.day,
+ }) {
+ if (groupBy == GroupAssetsBy.none) {
+ final query = _db.remoteAssetEntity.count(where: filter);
+ return query.map(_generateBuckets).watchSingle();
+ }
+
+ final assetCountExp = _db.remoteAssetEntity.id.count();
+ final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
+
+ final query = _db.remoteAssetEntity.selectOnly()
+ ..addColumns([assetCountExp, dateExp])
+ ..where(filter(_db.remoteAssetEntity))
+ ..groupBy([dateExp])
+ ..orderBy([OrderingTerm.desc(dateExp)]);
+
+ return query.map((row) {
+ final timeline = row.read(dateExp)!.dateFmt(groupBy);
+ final assetCount = row.read(assetCountExp)!;
+ return TimeBucket(date: timeline, assetCount: assetCount);
+ }).watch();
+ }
+
+ Future> _getRemoteAssets({
+ required Expression Function($RemoteAssetEntityTable row) filter,
+ required int offset,
+ required int count,
+ }) {
+ final query = _db.remoteAssetEntity.select()
+ ..where(filter)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset);
@@ -551,6 +352,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
}
+List _generateBuckets(int count) {
+ final buckets = List.generate(
+ (count / kTimelineNoneSegmentSize).floor(),
+ (_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
+ );
+ if (count % kTimelineNoneSegmentSize != 0) {
+ buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
+ }
+ return buckets;
+}
+
extension on Expression {
Expression dateFmt(GroupAssetsBy groupBy) {
// DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 1f18ecdf5a..bf79c28361 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -23,12 +23,12 @@ import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/background.service.dart';
+import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
-import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/migration.dart';
@@ -176,10 +176,13 @@ class ImmichAppState extends ConsumerState
final deepLinkHandler = ref.read(deepLinkServiceProvider);
final currentRouteName = ref.read(currentRouteNameProvider.notifier).state;
+ final isColdStart =
+ currentRouteName == null || currentRouteName == SplashScreenRoute.name;
+
if (deepLink.uri.scheme == "immich") {
final proposedRoute = await deepLinkHandler.handleScheme(
deepLink,
- currentRouteName == SplashScreenRoute.name,
+ isColdStart,
);
return proposedRoute;
@@ -188,7 +191,7 @@ class ImmichAppState extends ConsumerState
if (deepLink.uri.host == "my.immich.app") {
final proposedRoute = await deepLinkHandler.handleMyImmichApp(
deepLink,
- currentRouteName == SplashScreenRoute.name,
+ isColdStart,
);
return proposedRoute;
@@ -250,7 +253,8 @@ class ImmichAppState extends ConsumerState
),
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
- navigatorObservers: () => [AppNavigationObserver(ref: ref)],
+ navigatorObservers: () =>
+ [AppNavigationObserver(ref: ref), HeroController()],
),
),
);
diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart
index 0188d953dc..e713b3f8da 100644
--- a/mobile/lib/pages/common/tab_controller.page.dart
+++ b/mobile/lib/pages/common/tab_controller.page.dart
@@ -4,13 +4,13 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
+import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
+import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
-import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/providers/asset.provider.dart';
-import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class TabControllerPage extends HookConsumerWidget {
@@ -158,10 +158,6 @@ class TabControllerPage extends HookConsumerWidget {
),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
- final heroedChild = HeroControllerScope(
- controller: HeroController(),
- child: child,
- );
return PopScope(
canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) =>
@@ -173,10 +169,10 @@ class TabControllerPage extends HookConsumerWidget {
children: [
navigationRail(tabsRouter),
const VerticalDivider(),
- Expanded(child: heroedChild),
+ Expanded(child: child),
],
)
- : heroedChild,
+ : child,
bottomNavigationBar: multiselectEnabled || isScreenLandscape
? null
: bottomNavigationBar(tabsRouter),
diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart
index d5d53360b0..edaec6d336 100644
--- a/mobile/lib/pages/common/tab_shell.page.dart
+++ b/mobile/lib/pages/common/tab_shell.page.dart
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -127,10 +128,6 @@ class TabShellPage extends ConsumerWidget {
),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
- final heroedChild = HeroControllerScope(
- controller: HeroController(),
- child: child,
- );
return PopScope(
canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) =>
@@ -142,10 +139,10 @@ class TabShellPage extends ConsumerWidget {
children: [
navigationRail(tabsRouter),
const VerticalDivider(),
- Expanded(child: heroedChild),
+ Expanded(child: child),
],
)
- : heroedChild,
+ : child,
bottomNavigationBar: _BottomNavigationBar(
tabsRouter: tabsRouter,
destinations: navigationDestinations,
@@ -168,6 +165,11 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
ref.read(searchInputFocusProvider).requestFocus();
}
+ // Album page
+ if (index == 2) {
+ ref.read(remoteAlbumProvider.notifier).getAll();
+ }
+
ref.read(hapticFeedbackProvider.notifier).selectionClick();
router.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
diff --git a/mobile/lib/presentation/pages/dev/drift_partner_detail.page.dart b/mobile/lib/presentation/pages/dev/drift_partner_detail.page.dart
deleted file mode 100644
index 4b62387cb4..0000000000
--- a/mobile/lib/presentation/pages/dev/drift_partner_detail.page.dart
+++ /dev/null
@@ -1,29 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:flutter/widgets.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
-import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
-
-@RoutePage()
-class DriftPartnerDetailPage extends StatelessWidget {
- final String partnerId;
-
- const DriftPartnerDetailPage({super.key, required this.partnerId});
-
- @override
- Widget build(BuildContext context) {
- return ProviderScope(
- overrides: [
- timelineServiceProvider.overrideWith(
- (ref) {
- final timelineService =
- ref.watch(timelineFactoryProvider).remoteAssets(partnerId);
- ref.onDispose(timelineService.dispose);
- return timelineService;
- },
- ),
- ],
- child: const Timeline(),
- );
- }
-}
diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart
index 566c10c70a..bdb426fe22 100644
--- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart
+++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart
@@ -17,6 +17,21 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
final _features = [
+ _Feature(
+ name: 'Main Timeline',
+ icon: Icons.timeline_rounded,
+ onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
+ ),
+ _Feature(
+ name: 'Video',
+ icon: Icons.video_collection_outlined,
+ onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
+ ),
+ _Feature(
+ name: 'Recently Taken',
+ icon: Icons.schedule_outlined,
+ onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
+ ),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
@@ -42,23 +57,28 @@ final _features = [
return Future.value();
},
),
+ _Feature(
+ name: '',
+ icon: Icons.vertical_align_center_sharp,
+ onTap: (_, __) => Future.value(),
+ ),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
),
_Feature(
- name: 'Sync Local Full',
+ name: 'Sync Local Full (1)',
icon: Icons.photo_library_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
),
_Feature(
- name: 'Hash Local Assets',
+ name: 'Hash Local Assets (2)',
icon: Icons.numbers_outlined,
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
),
_Feature(
- name: 'Sync Remote',
+ name: 'Sync Remote (3)',
icon: Icons.refresh_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
),
@@ -69,6 +89,11 @@ final _features = [
.read(driftProvider)
.customStatement("pragma wal_checkpoint(truncate)"),
),
+ _Feature(
+ name: '',
+ icon: Icons.vertical_align_center_sharp,
+ onTap: (_, __) => Future.value(),
+ ),
_Feature(
name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded,
@@ -122,21 +147,6 @@ final _features = [
}
},
),
- _Feature(
- name: 'Main Timeline',
- icon: Icons.timeline_rounded,
- onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
- ),
- _Feature(
- name: 'Video',
- icon: Icons.video_collection_outlined,
- onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
- ),
- _Feature(
- name: 'Recently Taken',
- icon: Icons.schedule_outlined,
- onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
- ),
];
@RoutePage()
diff --git a/mobile/lib/presentation/pages/dev/local_timeline.page.dart b/mobile/lib/presentation/pages/dev/local_timeline.page.dart
deleted file mode 100644
index 3a98a81e9e..0000000000
--- a/mobile/lib/presentation/pages/dev/local_timeline.page.dart
+++ /dev/null
@@ -1,29 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:flutter/widgets.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
-import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
-
-@RoutePage()
-class LocalTimelinePage extends StatelessWidget {
- final String albumId;
-
- const LocalTimelinePage({super.key, required this.albumId});
-
- @override
- Widget build(BuildContext context) {
- return ProviderScope(
- overrides: [
- timelineServiceProvider.overrideWith(
- (ref) {
- final timelineService =
- ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId);
- ref.onDispose(timelineService.dispose);
- return timelineService;
- },
- ),
- ],
- child: const Timeline(),
- );
- }
-}
diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart
index 9ec8002463..7216b638e1 100644
--- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart
+++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart
@@ -16,17 +16,18 @@ class MainTimelinePage extends ConsumerWidget {
return memoryLaneProvider.when(
data: (memories) {
return memories.isEmpty
- ? const Timeline()
+ ? const Timeline(showStorageIndicator: true)
: Timeline(
topSliverWidget: SliverToBoxAdapter(
key: Key('memory-lane-${memories.first.assets.first.id}'),
child: DriftMemoryLane(memories: memories),
),
topSliverWidgetHeight: 200,
+ showStorageIndicator: true,
);
},
- loading: () => const Timeline(),
- error: (error, stackTrace) => const Timeline(),
+ loading: () => const Timeline(showStorageIndicator: true),
+ error: (error, stackTrace) => const Timeline(showStorageIndicator: true),
);
}
}
diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart
index e5745fa629..e61dcdf90d 100644
--- a/mobile/lib/presentation/pages/dev/media_stat.page.dart
+++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart
@@ -125,7 +125,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
name: album.name,
countFuture: countFuture,
onTap: () => context.router.push(
- LocalTimelineRoute(albumId: album.id),
+ LocalTimelineRoute(album: album),
),
);
},
@@ -226,7 +226,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
name: album.name,
countFuture: countFuture,
onTap: () => context.router.push(
- RemoteTimelineRoute(albumId: album.id),
+ RemoteAlbumRoute(album: album),
),
);
},
diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart
index 3298f12b65..e6d3d796a4 100644
--- a/mobile/lib/presentation/pages/drift_album.page.dart
+++ b/mobile/lib/presentation/pages/drift_album.page.dart
@@ -97,7 +97,20 @@ class _DriftAlbumsPageState extends ConsumerState {
onRefresh: onRefresh,
child: CustomScrollView(
slivers: [
- const ImmichSliverAppBar(),
+ ImmichSliverAppBar(
+ actions: [
+ IconButton(
+ icon: const Icon(
+ Icons.add_rounded,
+ size: 28,
+ ),
+ onPressed: () => context.pushRoute(
+ const DriftCreateAlbumRoute(),
+ ),
+ ),
+ ],
+ showUploadButton: false,
+ ),
_SearchBar(
searchController: searchController,
searchFocusNode: searchFocusNode,
@@ -475,7 +488,7 @@ class _AlbumList extends StatelessWidget {
final bool isLoading;
final String? error;
- final List albums;
+ final List albums;
final String? userId;
@override
@@ -555,7 +568,7 @@ class _AlbumList extends StatelessWidget {
),
),
onTap: () => context.router.push(
- RemoteTimelineRoute(albumId: album.id),
+ RemoteAlbumRoute(album: album),
),
leadingPadding: const EdgeInsets.only(
right: 16,
@@ -573,13 +586,24 @@ class _AlbumList extends StatelessWidget {
),
),
)
- : const SizedBox(
+ : SizedBox(
width: 80,
height: 80,
- child: Icon(
- Icons.photo_album_rounded,
- size: 40,
- color: Colors.grey,
+ child: Container(
+ decoration: BoxDecoration(
+ color: context.colorScheme.surfaceContainer,
+ borderRadius:
+ const BorderRadius.all(Radius.circular(16)),
+ border: Border.all(
+ color: context.colorScheme.outline.withAlpha(50),
+ width: 1,
+ ),
+ ),
+ child: const Icon(
+ Icons.photo_album_rounded,
+ size: 24,
+ color: Colors.grey,
+ ),
),
),
),
@@ -599,7 +623,7 @@ class _AlbumGrid extends StatelessWidget {
required this.error,
});
- final List albums;
+ final List albums;
final String? userId;
final bool isLoading;
final String? error;
@@ -674,14 +698,14 @@ class _GridAlbumCard extends StatelessWidget {
required this.userId,
});
- final Album album;
+ final RemoteAlbum album;
final String? userId;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.router.push(
- RemoteTimelineRoute(albumId: album.id),
+ RemoteAlbumRoute(album: album),
),
child: Card(
elevation: 0,
diff --git a/mobile/lib/presentation/pages/dev/drift_archive.page.dart b/mobile/lib/presentation/pages/drift_archive.page.dart
similarity index 66%
rename from mobile/lib/presentation/pages/dev/drift_archive.page.dart
rename to mobile/lib/presentation/pages/drift_archive.page.dart
index 14657f7149..90b8dcb646 100644
--- a/mobile/lib/presentation/pages/dev/drift_archive.page.dart
+++ b/mobile/lib/presentation/pages/drift_archive.page.dart
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart';
-import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/translate_extensions.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftArchivePage extends StatelessWidget {
@@ -27,7 +30,13 @@ class DriftArchivePage extends StatelessWidget {
},
),
],
- child: const Timeline(),
+ child: Timeline(
+ appBar: MesmerizingSliverAppBar(
+ title: 'archive'.t(context: context),
+ icon: Icons.archive_outlined,
+ ),
+ bottomSheet: const ArchiveBottomSheet(),
+ ),
);
}
}
diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
index df6211c338..7ac378e4f5 100644
--- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
+++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
@@ -33,7 +33,7 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception(
- 'User must be logged in to access recently taken',
+ 'User must be logged in to access asset selection timeline',
);
}
diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart
new file mode 100644
index 0000000000..e06321413e
--- /dev/null
+++ b/mobile/lib/presentation/pages/drift_create_album.page.dart
@@ -0,0 +1,500 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/extensions/translate_extensions.dart';
+import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
+import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
+
+@RoutePage()
+class DriftCreateAlbumPage extends ConsumerStatefulWidget {
+ const DriftCreateAlbumPage({super.key});
+
+ @override
+ ConsumerState createState() =>
+ _DriftCreateAlbumPageState();
+}
+
+class _DriftCreateAlbumPageState extends ConsumerState {
+ TextEditingController albumTitleController = TextEditingController();
+ TextEditingController albumDescriptionController = TextEditingController();
+ FocusNode albumTitleTextFieldFocusNode = FocusNode();
+ FocusNode albumDescriptionTextFieldFocusNode = FocusNode();
+ bool isAlbumTitleTextFieldFocus = false;
+ Set selectedAssets = {};
+
+ @override
+ void dispose() {
+ albumTitleController.dispose();
+ albumDescriptionController.dispose();
+ albumTitleTextFieldFocusNode.dispose();
+ albumDescriptionTextFieldFocusNode.dispose();
+ super.dispose();
+ }
+
+ bool get _canCreateAlbum => albumTitleController.text.isNotEmpty;
+
+ String _getEffectiveTitle() {
+ return albumTitleController.text.isNotEmpty
+ ? albumTitleController.text
+ : 'create_album_page_untitled'.t(context: context);
+ }
+
+ Widget _buildSliverAppBar() {
+ return SliverAppBar(
+ backgroundColor: context.scaffoldBackgroundColor,
+ elevation: 0,
+ automaticallyImplyLeading: false,
+ pinned: true,
+ snap: false,
+ floating: false,
+ bottom: PreferredSize(
+ preferredSize: const Size.fromHeight(200.0),
+ child: SizedBox(
+ height: 200,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ buildTitleInputField(),
+ buildDescriptionInputField(),
+ if (selectedAssets.isNotEmpty) buildControlButton(),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildContent() {
+ if (selectedAssets.isEmpty) {
+ return SliverList(
+ delegate: SliverChildListDelegate([
+ _buildEmptyState(),
+ _buildSelectPhotosButton(),
+ ]),
+ );
+ } else {
+ return _buildSelectedImageGrid();
+ }
+ }
+
+ Widget _buildEmptyState() {
+ return Padding(
+ padding: const EdgeInsets.only(top: 0, left: 18),
+ child: Text(
+ 'create_shared_album_page_share_add_assets',
+ style: context.textTheme.labelLarge,
+ ).t(),
+ );
+ }
+
+ Widget _buildSelectPhotosButton() {
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: FilledButton.icon(
+ style: FilledButton.styleFrom(
+ alignment: Alignment.centerLeft,
+ padding: const EdgeInsets.symmetric(
+ vertical: 24.0,
+ horizontal: 16.0,
+ ),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(Radius.circular(10.0)),
+ ),
+ backgroundColor: context.colorScheme.surfaceContainerHigh,
+ ),
+ onPressed: onSelectPhotos,
+ icon: Icon(Icons.add_rounded, color: context.primaryColor),
+ label: Padding(
+ padding: const EdgeInsets.only(
+ left: 8.0,
+ ),
+ child: Text(
+ 'create_shared_album_page_share_select_photos',
+ style: context.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: context.primaryColor,
+ ),
+ ).t(),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildSelectedImageGrid() {
+ return SliverPadding(
+ padding: const EdgeInsets.only(top: 16.0),
+ sliver: SliverGrid(
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 4,
+ crossAxisSpacing: 1.0,
+ mainAxisSpacing: 1.0,
+ ),
+ delegate: SliverChildBuilderDelegate(
+ (context, index) {
+ final asset = selectedAssets.elementAt(index);
+ return GestureDetector(
+ onTap: onBackgroundTapped,
+ child: Thumbnail(asset: asset),
+ );
+ },
+ childCount: selectedAssets.length,
+ ),
+ ),
+ );
+ }
+
+ void onBackgroundTapped() {
+ albumTitleTextFieldFocusNode.unfocus();
+ albumDescriptionTextFieldFocusNode.unfocus();
+ setState(() {
+ isAlbumTitleTextFieldFocus = false;
+ });
+
+ if (albumTitleController.text.isEmpty) {
+ final untitledText = 'create_album_page_untitled'.t();
+ albumTitleController.text = untitledText;
+ }
+ }
+
+ Future onSelectPhotos() async {
+ final assets = await context.pushRoute>(
+ DriftAssetSelectionTimelineRoute(
+ lockedSelectionAssets: selectedAssets,
+ ),
+ );
+
+ if (assets == null || assets.isEmpty) {
+ return;
+ }
+
+ setState(() {
+ selectedAssets = selectedAssets.union(assets);
+ });
+ }
+
+ Future createAlbum() async {
+ onBackgroundTapped();
+
+ final title = _getEffectiveTitle().trim();
+ if (title.isEmpty) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('create_album_title_required'.t()),
+ backgroundColor: context.colorScheme.error,
+ ),
+ );
+ }
+ return;
+ }
+
+ final album = await ref.watch(remoteAlbumProvider.notifier).createAlbum(
+ title: title,
+ description: albumDescriptionController.text.trim(),
+ assetIds: selectedAssets.map((asset) {
+ final remoteAsset = asset as RemoteAsset;
+ return remoteAsset.id;
+ }).toList(),
+ );
+
+ if (album != null) {
+ context.replaceRoute(
+ RemoteAlbumRoute(album: album),
+ );
+ }
+ }
+
+ Widget buildTitleInputField() {
+ return Padding(
+ padding: const EdgeInsets.only(
+ right: 10.0,
+ left: 10.0,
+ ),
+ child: _AlbumTitleTextField(
+ focusNode: albumTitleTextFieldFocusNode,
+ textController: albumTitleController,
+ isFocus: isAlbumTitleTextFieldFocus,
+ onFocusChanged: (focus) {
+ setState(() {
+ isAlbumTitleTextFieldFocus = focus;
+ });
+ },
+ ),
+ );
+ }
+
+ Widget buildDescriptionInputField() {
+ return Padding(
+ padding: const EdgeInsets.only(
+ right: 10.0,
+ left: 10.0,
+ top: 8,
+ ),
+ child: _AlbumViewerEditableDescription(
+ textController: albumDescriptionController,
+ focusNode: albumDescriptionTextFieldFocusNode,
+ ),
+ );
+ }
+
+ Widget buildControlButton() {
+ return Padding(
+ padding: const EdgeInsets.only(
+ left: 12.0,
+ top: 8.0,
+ bottom: 8.0,
+ ),
+ child: SizedBox(
+ height: 42.0,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ children: [
+ AlbumActionFilledButton(
+ iconData: Icons.add_photo_alternate_outlined,
+ onPressed: onSelectPhotos,
+ labelText: "add_photos".t(),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ elevation: 0,
+ centerTitle: false,
+ backgroundColor: context.scaffoldBackgroundColor,
+ leading: IconButton(
+ onPressed: () => context.maybePop(),
+ icon: const Icon(Icons.close_rounded),
+ ),
+ title: const Text('create_album').t(),
+ actions: [
+ TextButton(
+ onPressed: _canCreateAlbum ? createAlbum : null,
+ child: Text(
+ 'create'.t(),
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: _canCreateAlbum
+ ? context.primaryColor
+ : context.themeData.disabledColor,
+ ),
+ ),
+ ),
+ ],
+ ),
+ body: GestureDetector(
+ onTap: onBackgroundTapped,
+ child: CustomScrollView(
+ slivers: [
+ _buildSliverAppBar(),
+ _buildContent(),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _AlbumTitleTextField extends StatefulWidget {
+ const _AlbumTitleTextField({
+ required this.focusNode,
+ required this.textController,
+ required this.isFocus,
+ required this.onFocusChanged,
+ });
+
+ final FocusNode focusNode;
+ final TextEditingController textController;
+ final bool isFocus;
+ final ValueChanged onFocusChanged;
+
+ @override
+ State<_AlbumTitleTextField> createState() => _AlbumTitleTextFieldState();
+}
+
+class _AlbumTitleTextFieldState extends State<_AlbumTitleTextField> {
+ @override
+ void initState() {
+ super.initState();
+ widget.focusNode.addListener(_onFocusChange);
+ }
+
+ @override
+ void dispose() {
+ widget.focusNode.removeListener(_onFocusChange);
+ super.dispose();
+ }
+
+ void _onFocusChange() {
+ widget.onFocusChanged(widget.focusNode.hasFocus);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return TextField(
+ focusNode: widget.focusNode,
+ style: TextStyle(
+ fontSize: 28.0,
+ color: context.colorScheme.onSurface,
+ fontWeight: FontWeight.bold,
+ ),
+ controller: widget.textController,
+ onTap: () {
+ if (widget.textController.text ==
+ 'create_album_page_untitled'.t(context: context)) {
+ widget.textController.clear();
+ }
+ },
+ decoration: InputDecoration(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 8.0,
+ vertical: 16.0,
+ ),
+ suffixIcon: widget.textController.text.isNotEmpty && widget.isFocus
+ ? IconButton(
+ onPressed: () {
+ widget.textController.clear();
+ },
+ icon: Icon(
+ Icons.cancel_rounded,
+ color: context.primaryColor,
+ ),
+ splashRadius: 10.0,
+ )
+ : null,
+ enabledBorder: const OutlineInputBorder(
+ borderSide: BorderSide(color: Colors.transparent),
+ borderRadius: BorderRadius.all(
+ Radius.circular(16.0),
+ ),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: context.primaryColor.withValues(alpha: 0.3),
+ ),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(16.0),
+ ),
+ ),
+ hintText: 'add_a_title'.t(),
+ hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(
+ fontSize: 28.0,
+ fontWeight: FontWeight.bold,
+ height: 1.2,
+ ),
+ focusColor: Colors.grey[300],
+ fillColor: context.colorScheme.surfaceContainerHigh,
+ filled: true,
+ ),
+ );
+ }
+}
+
+class _AlbumViewerEditableDescription extends StatefulWidget {
+ const _AlbumViewerEditableDescription({
+ required this.textController,
+ required this.focusNode,
+ });
+
+ final TextEditingController textController;
+ final FocusNode focusNode;
+
+ @override
+ State<_AlbumViewerEditableDescription> createState() =>
+ _AlbumViewerEditableDescriptionState();
+}
+
+class _AlbumViewerEditableDescriptionState
+ extends State<_AlbumViewerEditableDescription> {
+ @override
+ void initState() {
+ super.initState();
+ widget.focusNode.addListener(_onFocusModeChange);
+ widget.textController.addListener(_onTextChange);
+ }
+
+ @override
+ void dispose() {
+ widget.focusNode.removeListener(_onFocusModeChange);
+ widget.textController.removeListener(_onTextChange);
+ super.dispose();
+ }
+
+ void _onFocusModeChange() {
+ setState(() {
+ if (!widget.focusNode.hasFocus && widget.textController.text.isEmpty) {
+ widget.textController.clear();
+ }
+ });
+ }
+
+ void _onTextChange() {
+ setState(() {});
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.transparent,
+ child: TextField(
+ focusNode: widget.focusNode,
+ style: context.textTheme.bodyLarge,
+ maxLines: 3,
+ minLines: 1,
+ controller: widget.textController,
+ decoration: InputDecoration(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 12.0,
+ vertical: 16.0,
+ ),
+ suffixIcon:
+ widget.focusNode.hasFocus && widget.textController.text.isNotEmpty
+ ? IconButton(
+ onPressed: () {
+ widget.textController.clear();
+ },
+ icon: Icon(
+ Icons.cancel_rounded,
+ color: context.primaryColor,
+ ),
+ splashRadius: 10.0,
+ )
+ : null,
+ enabledBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: context.colorScheme.outline.withValues(alpha: 0.3),
+ ),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(16.0),
+ ),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: context.primaryColor.withValues(alpha: 0.3),
+ ),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(16.0),
+ ),
+ ),
+ hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(
+ fontSize: 16.0,
+ color: context.colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ focusColor: Colors.grey[300],
+ fillColor: context.scaffoldBackgroundColor,
+ filled: widget.focusNode.hasFocus,
+ hintText: 'add_a_description'.t(),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/presentation/pages/dev/drift_favorite.page.dart b/mobile/lib/presentation/pages/drift_favorite.page.dart
similarity index 66%
rename from mobile/lib/presentation/pages/dev/drift_favorite.page.dart
rename to mobile/lib/presentation/pages/drift_favorite.page.dart
index 4055ad863b..90a273f93b 100644
--- a/mobile/lib/presentation/pages/dev/drift_favorite.page.dart
+++ b/mobile/lib/presentation/pages/drift_favorite.page.dart
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart';
-import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/translate_extensions.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftFavoritePage extends StatelessWidget {
@@ -27,7 +30,13 @@ class DriftFavoritePage extends StatelessWidget {
},
),
],
- child: const Timeline(),
+ child: Timeline(
+ appBar: MesmerizingSliverAppBar(
+ title: 'favorites'.t(context: context),
+ icon: Icons.favorite_outline,
+ ),
+ bottomSheet: const FavoriteBottomSheet(),
+ ),
);
}
}
diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart
index 0efa8040e7..1e8975dcfa 100644
--- a/mobile/lib/presentation/pages/drift_library.page.dart
+++ b/mobile/lib/presentation/pages/drift_library.page.dart
@@ -27,7 +27,12 @@ class DriftLibraryPage extends ConsumerWidget {
return const Scaffold(
body: CustomScrollView(
slivers: [
- ImmichSliverAppBar(),
+ ImmichSliverAppBar(
+ snap: false,
+ floating: false,
+ pinned: true,
+ showUploadButton: false,
+ ),
_ActionButtonGrid(),
_CollectionCards(),
_QuickAccessButtonList(),
@@ -507,8 +512,9 @@ class _PartnerList extends StatelessWidget {
fontWeight: FontWeight.w500,
),
).t(context: context, args: {'user': partner.name}),
- onTap: () =>
- context.pushRoute(DriftPartnerDetailRoute(partnerId: partner.id)),
+ onTap: () => context.pushRoute(
+ DriftPartnerDetailRoute(partner: partner),
+ ),
);
},
);
diff --git a/mobile/lib/presentation/pages/dev/drift_local_album.page.dart b/mobile/lib/presentation/pages/drift_local_album.page.dart
similarity index 99%
rename from mobile/lib/presentation/pages/dev/drift_local_album.page.dart
rename to mobile/lib/presentation/pages/drift_local_album.page.dart
index f47811b6da..0ad9abd2fa 100644
--- a/mobile/lib/presentation/pages/dev/drift_local_album.page.dart
+++ b/mobile/lib/presentation/pages/drift_local_album.page.dart
@@ -103,7 +103,7 @@ class _AlbumList extends ConsumerWidget {
),
),
onTap: () =>
- context.pushRoute(LocalTimelineRoute(albumId: album.id)),
+ context.pushRoute(LocalTimelineRoute(album: album)),
),
);
},
diff --git a/mobile/lib/presentation/pages/dev/drift_locked_folder.page.dart b/mobile/lib/presentation/pages/drift_locked_folder.page.dart
similarity index 70%
rename from mobile/lib/presentation/pages/dev/drift_locked_folder.page.dart
rename to mobile/lib/presentation/pages/drift_locked_folder.page.dart
index 5ab7c71347..9b42cdb103 100644
--- a/mobile/lib/presentation/pages/dev/drift_locked_folder.page.dart
+++ b/mobile/lib/presentation/pages/drift_locked_folder.page.dart
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/translate_extensions.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftLockedFolderPage extends StatelessWidget {
@@ -27,7 +30,12 @@ class DriftLockedFolderPage extends StatelessWidget {
},
),
],
- child: const Timeline(),
+ child: Timeline(
+ appBar: MesmerizingSliverAppBar(
+ title: 'locked_folder'.t(context: context),
+ ),
+ bottomSheet: const LockedFolderBottomSheet(),
+ ),
);
}
}
diff --git a/mobile/lib/presentation/pages/drift_partner_detail.page.dart b/mobile/lib/presentation/pages/drift_partner_detail.page.dart
new file mode 100644
index 0000000000..baae893d39
--- /dev/null
+++ b/mobile/lib/presentation/pages/drift_partner_detail.page.dart
@@ -0,0 +1,109 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/user.model.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart';
+import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
+import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
+
+@RoutePage()
+class DriftPartnerDetailPage extends StatelessWidget {
+ final UserDto partner;
+
+ const DriftPartnerDetailPage({
+ super.key,
+ required this.partner,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ProviderScope(
+ overrides: [
+ timelineServiceProvider.overrideWith(
+ (ref) {
+ final timelineService =
+ ref.watch(timelineFactoryProvider).remoteAssets(partner.id);
+ ref.onDispose(timelineService.dispose);
+ return timelineService;
+ },
+ ),
+ ],
+ child: Timeline(
+ appBar: MesmerizingSliverAppBar(
+ title: partner.name,
+ icon: Icons.person_outline,
+ ),
+ topSliverWidget: _InfoBox(
+ onTap: () => {
+ // TODO: Create DriftUserProvider/DriftUserService to handle this action
+ },
+ inTimeline: partner.inTimeline,
+ ),
+ topSliverWidgetHeight: 110,
+ bottomSheet: const PartnerDetailBottomSheet(),
+ ),
+ );
+ }
+}
+
+class _InfoBox extends StatelessWidget {
+ final VoidCallback onTap;
+ final bool inTimeline;
+
+ const _InfoBox({
+ required this.onTap,
+ required this.inTimeline,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return SliverToBoxAdapter(
+ child: SizedBox(
+ height: 110,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: context.colorScheme.onSurface.withAlpha(10),
+ width: 1,
+ ),
+ borderRadius: const BorderRadius.all(
+ Radius.circular(20),
+ ),
+ gradient: LinearGradient(
+ colors: [
+ context.colorScheme.primary.withAlpha(10),
+ context.colorScheme.primary.withAlpha(15),
+ ],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: ListTile(
+ title: Text(
+ "Show in timeline",
+ style: context.textTheme.titleSmall?.copyWith(
+ color: context.colorScheme.primary,
+ ),
+ ),
+ subtitle: Text(
+ "Show photos and videos from this user in your timeline",
+ style: context.textTheme.bodyMedium,
+ ),
+ trailing: Switch(
+ value: inTimeline,
+ onChanged: (_) => onTap(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/presentation/pages/dev/drift_recently_taken.page.dart b/mobile/lib/presentation/pages/drift_recently_taken.page.dart
similarity index 100%
rename from mobile/lib/presentation/pages/dev/drift_recently_taken.page.dart
rename to mobile/lib/presentation/pages/drift_recently_taken.page.dart
diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart
new file mode 100644
index 0000000000..bbfe6ddc74
--- /dev/null
+++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart
@@ -0,0 +1,41 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/album/album.model.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart';
+import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
+import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
+
+@RoutePage()
+class RemoteAlbumPage extends StatelessWidget {
+ final RemoteAlbum album;
+
+ const RemoteAlbumPage({super.key, required this.album});
+
+ @override
+ Widget build(BuildContext context) {
+ return ProviderScope(
+ overrides: [
+ timelineServiceProvider.overrideWith(
+ (ref) {
+ final timelineService = ref
+ .watch(timelineFactoryProvider)
+ .remoteAlbum(albumId: album.id);
+ ref.onDispose(timelineService.dispose);
+ return timelineService;
+ },
+ ),
+ ],
+ child: Timeline(
+ appBar: MesmerizingSliverAppBar(
+ title: album.name,
+ icon: Icons.photo_album_outlined,
+ ),
+ bottomSheet: RemoteAlbumBottomSheet(
+ album: album,
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/presentation/pages/dev/drift_trash.page.dart b/mobile/lib/presentation/pages/drift_trash.page.dart
similarity index 73%
rename from mobile/lib/presentation/pages/dev/drift_trash.page.dart
rename to mobile/lib/presentation/pages/drift_trash.page.dart
index cbcfe50112..9cd2fac760 100644
--- a/mobile/lib/presentation/pages/dev/drift_trash.page.dart
+++ b/mobile/lib/presentation/pages/drift_trash.page.dart
@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart';
-import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -27,7 +28,16 @@ class DriftTrashPage extends StatelessWidget {
},
),
],
- child: const Timeline(),
+ child: Timeline(
+ appBar: SliverAppBar(
+ title: Text('trash'.t(context: context)),
+ floating: true,
+ snap: true,
+ pinned: true,
+ centerTitle: true,
+ elevation: 0,
+ ),
+ ),
);
}
}
diff --git a/mobile/lib/presentation/pages/dev/drift_video.page.dart b/mobile/lib/presentation/pages/drift_video.page.dart
similarity index 100%
rename from mobile/lib/presentation/pages/dev/drift_video.page.dart
rename to mobile/lib/presentation/pages/drift_video.page.dart
diff --git a/mobile/lib/presentation/pages/dev/remote_timeline.page.dart b/mobile/lib/presentation/pages/local_timeline.page.dart
similarity index 54%
rename from mobile/lib/presentation/pages/dev/remote_timeline.page.dart
rename to mobile/lib/presentation/pages/local_timeline.page.dart
index 6568f0f74f..b4df0f64e2 100644
--- a/mobile/lib/presentation/pages/dev/remote_timeline.page.dart
+++ b/mobile/lib/presentation/pages/local_timeline.page.dart
@@ -1,14 +1,17 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/album/local_album.model.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
-class RemoteTimelinePage extends StatelessWidget {
- final String albumId;
+class LocalTimelinePage extends StatelessWidget {
+ final LocalAlbum album;
- const RemoteTimelinePage({super.key, required this.albumId});
+ const LocalTimelinePage({super.key, required this.album});
@override
Widget build(BuildContext context) {
@@ -18,13 +21,16 @@ class RemoteTimelinePage extends StatelessWidget {
(ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
- .remoteAlbum(albumId: albumId);
+ .localAlbum(albumId: album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
},
),
],
- child: const Timeline(),
+ child: Timeline(
+ appBar: MesmerizingSliverAppBar(title: album.name),
+ bottomSheet: const LocalAlbumBottomSheet(),
+ ),
);
}
}
diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart
index b18e10ebba..8228e27cc1 100644
--- a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart
+++ b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart
@@ -1,16 +1,56 @@
import 'package:flutter/material.dart';
+import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
+import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
+import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
+import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoveFromAlbumActionButton extends ConsumerWidget {
- const RemoveFromAlbumActionButton({super.key});
+ final String albumId;
+ final ActionSource source;
+
+ const RemoveFromAlbumActionButton({
+ super.key,
+ required this.albumId,
+ required this.source,
+ });
+
+ void _onTap(BuildContext context, WidgetRef ref) async {
+ if (!context.mounted) {
+ return;
+ }
+
+ final result = await ref
+ .read(actionProvider.notifier)
+ .removeFromAlbum(source, albumId);
+ ref.read(multiSelectProvider.notifier).reset();
+
+ final successMessage = 'remove_from_album_action_prompt'.t(
+ context: context,
+ args: {'count': result.count.toString()},
+ );
+
+ if (context.mounted) {
+ ImmichToast.show(
+ context: context,
+ msg: result.success
+ ? successMessage
+ : 'scaffold_body_error_occurred'.t(context: context),
+ gravity: ToastGravity.BOTTOM,
+ toastType: result.success ? ToastType.success : ToastType.error,
+ );
+ }
+ }
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context),
+ onPressed: () => _onTap(context, ref),
);
}
}
diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart
index a58d9f1ee1..e44500144c 100644
--- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart
+++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart
@@ -8,10 +8,10 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
-class UnarchiveActionButton extends ConsumerWidget {
+class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source;
- const UnarchiveActionButton({super.key, required this.source});
+ const UnArchiveActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
index aea821d945..b41d6ba2c7 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
@@ -71,6 +71,7 @@ class _AssetViewerState extends ConsumerState {
StreamSubscription? reloadSubscription;
late Platform platform;
+ late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown;
bool isSnapping = false;
@@ -98,6 +99,7 @@ class _AssetViewerState extends ConsumerState {
_onAssetChanged(widget.initialIndex);
});
reloadSubscription = EventStream.shared.listen(_onEvent);
+ heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
}
@override
@@ -335,7 +337,7 @@ class _AssetViewerState extends ConsumerState {
final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down
- if (isDraggingDown && delta.extent < 0.5) {
+ if (isDraggingDown && delta.extent < 0.55) {
if (dragInProgress) {
blockGestures = true;
}
@@ -400,7 +402,7 @@ class _AssetViewerState extends ConsumerState {
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
- sheetAnimationStyle: AnimationStyle(
+ sheetAnimationStyle: const AnimationStyle(
duration: Durations.short4,
reverseDuration: Durations.short2,
),
@@ -487,7 +489,8 @@ class _AssetViewerState extends ConsumerState {
return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size),
- heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
+ heroAttributes:
+ PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999,
@@ -521,7 +524,8 @@ class _AssetViewerState extends ConsumerState {
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
- heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
+ heroAttributes:
+ PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0,
diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart
index d0bdc28d10..a7a2a57ce5 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart
@@ -16,7 +16,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart';
-import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart
new file mode 100644
index 0000000000..c23f268465
--- /dev/null
+++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
+import 'package:immich_mobile/providers/server_info.provider.dart';
+import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
+
+class ArchiveBottomSheet extends ConsumerWidget {
+ const ArchiveBottomSheet({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final multiselect = ref.watch(multiSelectProvider);
+ final isTrashEnable = ref.watch(
+ serverInfoProvider.select((state) => state.serverFeatures.trash),
+ );
+
+ return BaseBottomSheet(
+ initialChildSize: 0.25,
+ maxChildSize: 0.4,
+ shouldCloseOnMinExtent: false,
+ actions: [
+ const ShareActionButton(),
+ if (multiselect.hasRemote) ...[
+ const ShareLinkActionButton(source: ActionSource.timeline),
+ const UnArchiveActionButton(source: ActionSource.timeline),
+ const FavoriteActionButton(source: ActionSource.timeline),
+ const DownloadActionButton(),
+ isTrashEnable
+ ? const TrashActionButton(source: ActionSource.timeline)
+ : const DeletePermanentActionButton(
+ source: ActionSource.timeline,
+ ),
+ const EditDateTimeActionButton(),
+ const EditLocationActionButton(source: ActionSource.timeline),
+ const MoveToLockFolderActionButton(
+ source: ActionSource.timeline,
+ ),
+ const StackActionButton(),
+ ],
+ if (multiselect.hasLocal) ...[
+ const DeleteLocalActionButton(),
+ const UploadActionButton(),
+ ],
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart
similarity index 100%
rename from mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart
rename to mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart
new file mode 100644
index 0000000000..2f8208a80b
--- /dev/null
+++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
+import 'package:immich_mobile/providers/server_info.provider.dart';
+import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
+
+class FavoriteBottomSheet extends ConsumerWidget {
+ const FavoriteBottomSheet({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final multiselect = ref.watch(multiSelectProvider);
+ final isTrashEnable = ref.watch(
+ serverInfoProvider.select((state) => state.serverFeatures.trash),
+ );
+
+ return BaseBottomSheet(
+ initialChildSize: 0.25,
+ maxChildSize: 0.4,
+ shouldCloseOnMinExtent: false,
+ actions: [
+ const ShareActionButton(),
+ if (multiselect.hasRemote) ...[
+ const ShareLinkActionButton(source: ActionSource.timeline),
+ const UnFavoriteActionButton(source: ActionSource.timeline),
+ const ArchiveActionButton(source: ActionSource.timeline),
+ const DownloadActionButton(),
+ isTrashEnable
+ ? const TrashActionButton(source: ActionSource.timeline)
+ : const DeletePermanentActionButton(
+ source: ActionSource.timeline,
+ ),
+ const EditDateTimeActionButton(),
+ const EditLocationActionButton(source: ActionSource.timeline),
+ const MoveToLockFolderActionButton(
+ source: ActionSource.timeline,
+ ),
+ const StackActionButton(),
+ ],
+ if (multiselect.hasLocal) ...[
+ const DeleteLocalActionButton(),
+ const UploadActionButton(),
+ ],
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart
similarity index 92%
rename from mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart
rename to mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart
index f5ba759130..900adefd0b 100644
--- a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart
+++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart
@@ -14,12 +14,12 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
-import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
-class HomeBottomAppBar extends ConsumerWidget {
- const HomeBottomAppBar({super.key});
+class GeneralBottomSheet extends ConsumerWidget {
+ const GeneralBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -30,9 +30,10 @@ class HomeBottomAppBar extends ConsumerWidget {
return BaseBottomSheet(
initialChildSize: 0.25,
+ maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
- if (multiselect.isEnabled) const ShareActionButton(),
+ const ShareActionButton(),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart
new file mode 100644
index 0000000000..3fd717f516
--- /dev/null
+++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart
@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
+
+class LocalAlbumBottomSheet extends ConsumerWidget {
+ const LocalAlbumBottomSheet({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return const BaseBottomSheet(
+ initialChildSize: 0.25,
+ maxChildSize: 0.4,
+ shouldCloseOnMinExtent: false,
+ actions: [
+ ShareActionButton(),
+ DeleteLocalActionButton(),
+ UploadActionButton(),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
new file mode 100644
index 0000000000..97b2646f32
--- /dev/null
+++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
+
+class LockedFolderBottomSheet extends ConsumerWidget {
+ const LockedFolderBottomSheet({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return const BaseBottomSheet(
+ initialChildSize: 0.25,
+ maxChildSize: 0.4,
+ shouldCloseOnMinExtent: false,
+ actions: [
+ ShareActionButton(),
+ DownloadActionButton(),
+ DeletePermanentActionButton(source: ActionSource.timeline),
+ RemoveFromLockFolderActionButton(source: ActionSource.timeline),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart
new file mode 100644
index 0000000000..7af8ab7c86
--- /dev/null
+++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
+
+class PartnerDetailBottomSheet extends ConsumerWidget {
+ const PartnerDetailBottomSheet({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return const BaseBottomSheet(
+ initialChildSize: 0.25,
+ maxChildSize: 0.4,
+ shouldCloseOnMinExtent: false,
+ actions: [
+ ShareActionButton(),
+ DownloadActionButton(),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart
new file mode 100644
index 0000000000..0268a2b386
--- /dev/null
+++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart
@@ -0,0 +1,68 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
+import 'package:immich_mobile/domain/models/album/album.model.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
+import 'package:immich_mobile/providers/server_info.provider.dart';
+import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
+
+class RemoteAlbumBottomSheet extends ConsumerWidget {
+ final RemoteAlbum album;
+ const RemoteAlbumBottomSheet({super.key, required this.album});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final multiselect = ref.watch(multiSelectProvider);
+ final isTrashEnable = ref.watch(
+ serverInfoProvider.select((state) => state.serverFeatures.trash),
+ );
+
+ return BaseBottomSheet(
+ initialChildSize: 0.25,
+ maxChildSize: 0.4,
+ shouldCloseOnMinExtent: false,
+ actions: [
+ const ShareActionButton(),
+ if (multiselect.hasRemote) ...[
+ const ShareLinkActionButton(source: ActionSource.timeline),
+ const ArchiveActionButton(source: ActionSource.timeline),
+ const FavoriteActionButton(source: ActionSource.timeline),
+ const DownloadActionButton(),
+ isTrashEnable
+ ? const TrashActionButton(source: ActionSource.timeline)
+ : const DeletePermanentActionButton(
+ source: ActionSource.timeline,
+ ),
+ const EditDateTimeActionButton(),
+ const EditLocationActionButton(source: ActionSource.timeline),
+ const MoveToLockFolderActionButton(
+ source: ActionSource.timeline,
+ ),
+ const StackActionButton(),
+ ],
+ if (multiselect.hasLocal) ...[
+ const DeleteLocalActionButton(),
+ const UploadActionButton(),
+ ],
+ RemoveFromAlbumActionButton(
+ source: ActionSource.timeline,
+ albumId: album.id,
+ ),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart
index d7261c927e..7e3776adb2 100644
--- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart
+++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart
@@ -1,3 +1,4 @@
+import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -25,6 +26,8 @@ class ThumbnailTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
+
final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4)
: context.primaryColor.lighten(amount: 0.75);
@@ -64,7 +67,7 @@ class ThumbnailTile extends ConsumerWidget {
children: [
Positioned.fill(
child: Hero(
- tag: asset.heroTag,
+ tag: '${asset.heroTag}_$heroOffset',
child: Thumbnail(
asset: asset,
fit: fit,
diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
index 14b1a4616d..d12f82d27d 100644
--- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
+++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
@@ -159,17 +159,18 @@ class _AssetTileWidget extends ConsumerWidget {
required this.assetIndex,
});
- void _handleOnTap(
+ Future _handleOnTap(
BuildContext ctx,
WidgetRef ref,
int assetIndex,
BaseAsset asset,
- ) {
+ ) async {
final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
} else {
+ await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
ctx.pushRoute(
AssetViewerRoute(
initialIndex: assetIndex,
@@ -206,6 +207,9 @@ class _AssetTileWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final lockSelection = _getLockSelectionStatus(ref);
+ final showStorageIndicator = ref.watch(
+ timelineArgsProvider.select((args) => args.showStorageIndicator),
+ );
return RepaintBoundary(
child: GestureDetector(
@@ -217,6 +221,7 @@ class _AssetTileWidget extends ConsumerWidget {
child: ThumbnailTile(
asset,
lockSelection: lockSelection,
+ showStorageIndicator: showStorageIndicator,
),
),
);
diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart
index 629fac7831..30a4088ce2 100644
--- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart
+++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart
@@ -14,12 +14,14 @@ class TimelineArgs {
final double maxHeight;
final double spacing;
final int columnCount;
+ final bool showStorageIndicator;
const TimelineArgs({
required this.maxWidth,
required this.maxHeight,
this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount,
+ this.showStorageIndicator = false,
});
@override
@@ -27,7 +29,8 @@ class TimelineArgs {
return spacing == other.spacing &&
maxWidth == other.maxWidth &&
maxHeight == other.maxHeight &&
- columnCount == other.columnCount;
+ columnCount == other.columnCount &&
+ showStorageIndicator == other.showStorageIndicator;
}
@override
@@ -35,7 +38,8 @@ class TimelineArgs {
maxWidth.hashCode ^
maxHeight.hashCode ^
spacing.hashCode ^
- columnCount.hashCode;
+ columnCount.hashCode ^
+ showStorageIndicator.hashCode;
}
class TimelineState {
diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
index 9fb164e2dc..490f2bcff2 100644
--- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
+++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart
@@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:immich_mobile/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart';
+import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
+import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class Timeline extends StatelessWidget {
@@ -25,11 +26,16 @@ class Timeline extends StatelessWidget {
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
+ this.showStorageIndicator = false,
+ this.appBar,
+ this.bottomSheet = const GeneralBottomSheet(),
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
-
+ final bool showStorageIndicator;
+ final Widget? appBar;
+ final Widget? bottomSheet;
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -43,12 +49,15 @@ class Timeline extends StatelessWidget {
columnCount: ref.watch(
settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
),
+ showStorageIndicator: showStorageIndicator,
),
),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
+ appBar: appBar,
+ bottomSheet: bottomSheet,
),
),
),
@@ -60,10 +69,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
+ this.appBar,
+ this.bottomSheet,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
+ final Widget? appBar;
+ final Widget? bottomSheet;
@override
ConsumerState createState() => _SliverTimelineState();
@@ -100,6 +113,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final statusBarHeight = context.padding.top;
+ final double appBarExpandedHeight =
+ widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
+ ? 200
+ : 0;
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
const scrubberBottomPadding = 100.0;
@@ -112,7 +129,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineHeight: maxHeight,
topPadding: totalAppBarHeight + 10,
bottomPadding: context.padding.bottom + scrubberBottomPadding,
- monthSegmentSnappingOffset: widget.topSliverWidgetHeight,
+ monthSegmentSnappingOffset:
+ widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
cacheExtent: maxHeight * 2,
@@ -120,11 +138,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
if (isSelectionMode)
const SelectionSliverAppBar()
else
- const ImmichSliverAppBar(
- floating: true,
- pinned: false,
- snap: false,
- ),
+ widget.appBar ??
+ const ImmichSliverAppBar(
+ floating: true,
+ pinned: false,
+ snap: false,
+ ),
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
@@ -182,7 +201,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
return const SizedBox.shrink();
},
- child: const HomeBottomAppBar(),
+ child: widget.bottomSheet,
),
],
],
diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart
index 0c197ca683..49605e918a 100644
--- a/mobile/lib/providers/infrastructure/action.provider.dart
+++ b/mobile/lib/providers/infrastructure/action.provider.dart
@@ -228,6 +228,24 @@ class ActionNotifier extends Notifier {
);
}
}
+
+ Future removeFromAlbum(
+ ActionSource source,
+ String albumId,
+ ) async {
+ final ids = _getRemoteIdsForSource(source);
+ try {
+ final removedCount = await _service.removeFromAlbum(ids, albumId);
+ return ActionResult(count: removedCount, success: true);
+ } catch (error, stack) {
+ _logger.severe('Failed to remove assets from album', error, stack);
+ return ActionResult(
+ count: ids.length,
+ success: false,
+ error: error.toString(),
+ );
+ }
+ }
}
extension on Iterable {
diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart
index 4a6db50697..4ec3453d16 100644
--- a/mobile/lib/providers/infrastructure/album.provider.dart
+++ b/mobile/lib/providers/infrastructure/album.provider.dart
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
+import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
final localAlbumRepository = Provider(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@@ -30,7 +31,10 @@ final remoteAlbumRepository = Provider(
);
final remoteAlbumServiceProvider = Provider(
- (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository)),
+ (ref) => RemoteAlbumService(
+ ref.watch(remoteAlbumRepository),
+ ref.watch(driftAlbumApiRepositoryProvider),
+ ),
dependencies: [remoteAlbumRepository],
);
diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart
index 2e5a475b9c..84db53ab9f 100644
--- a/mobile/lib/providers/infrastructure/remote_album.provider.dart
+++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart
@@ -8,21 +8,21 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'album.provider.dart';
class RemoteAlbumState {
- final List albums;
- final List filteredAlbums;
+ final List albums;
+ final List filteredAlbums;
final bool isLoading;
final String? error;
const RemoteAlbumState({
required this.albums,
- List? filteredAlbums,
+ List? filteredAlbums,
this.isLoading = false,
this.error,
}) : filteredAlbums = filteredAlbums ?? albums;
RemoteAlbumState copyWith({
- List? albums,
- List? filteredAlbums,
+ List? albums,
+ List? filteredAlbums,
bool? isLoading,
String? error,
}) {
@@ -66,7 +66,7 @@ class RemoteAlbumNotifier extends Notifier {
return const RemoteAlbumState(albums: [], filteredAlbums: []);
}
- Future> getAll() async {
+ Future> getAll() async {
state = state.copyWith(isLoading: true, error: null);
try {
@@ -118,4 +118,31 @@ class RemoteAlbumNotifier extends Notifier {
.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums);
}
+
+ Future createAlbum({
+ required String title,
+ String? description,
+ List assetIds = const [],
+ }) async {
+ state = state.copyWith(isLoading: true, error: null);
+
+ try {
+ final album = await _remoteAlbumService.createAlbum(
+ title: title,
+ description: description,
+ assetIds: assetIds,
+ );
+
+ state = state.copyWith(
+ albums: [...state.albums, album],
+ filteredAlbums: [...state.filteredAlbums, album],
+ );
+
+ state = state.copyWith(isLoading: false);
+ return album;
+ } catch (e) {
+ state = state.copyWith(isLoading: false, error: e.toString());
+ rethrow;
+ }
+ }
}
diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart
index 019e4dc63c..20365534c2 100644
--- a/mobile/lib/repositories/album_api.repository.dart
+++ b/mobile/lib/repositories/album_api.repository.dart
@@ -1,5 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
+import 'package:immich_mobile/domain/models/album/album.model.dart'
+ show AlbumAssetOrder, RemoteAlbum;
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'
@@ -50,6 +52,25 @@ class AlbumApiRepository extends ApiRepository {
return _toAlbum(responseDto);
}
+ // TODO: Change name after removing old method
+ Future createDriftAlbum(
+ String name, {
+ required Iterable assetIds,
+ String? description,
+ }) async {
+ final responseDto = await checkNull(
+ _api.createAlbum(
+ CreateAlbumDto(
+ albumName: name,
+ description: description,
+ assetIds: assetIds.toList(),
+ ),
+ ),
+ );
+
+ return _toRemoteAlbum(responseDto);
+ }
+
Future update(
String albumId, {
String? name,
@@ -170,4 +191,22 @@ class AlbumApiRepository extends ApiRepository {
return album;
}
+
+ static RemoteAlbum _toRemoteAlbum(AlbumResponseDto dto) {
+ return RemoteAlbum(
+ id: dto.id,
+ name: dto.albumName,
+ ownerId: dto.owner.id,
+ description: dto.description,
+ createdAt: dto.createdAt,
+ updatedAt: dto.updatedAt,
+ thumbnailAssetId: dto.albumThumbnailAssetId,
+ isActivityEnabled: dto.isActivityEnabled,
+ order: dto.order == AssetOrder.asc
+ ? AlbumAssetOrder.asc
+ : AlbumAssetOrder.desc,
+ assetCount: dto.assetCount,
+ ownerName: dto.owner.name,
+ );
+ }
}
diff --git a/mobile/lib/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart
new file mode 100644
index 0000000000..7ef24f1e7c
--- /dev/null
+++ b/mobile/lib/repositories/drift_album_api_repository.dart
@@ -0,0 +1,74 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/album/album.model.dart';
+import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/repositories/api.repository.dart';
+// ignore: import_rule_openapi
+import 'package:openapi/api.dart';
+
+final driftAlbumApiRepositoryProvider = Provider(
+ (ref) => DriftAlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
+);
+
+class DriftAlbumApiRepository extends ApiRepository {
+ final AlbumsApi _api;
+
+ DriftAlbumApiRepository(this._api);
+
+ Future createDriftAlbum(
+ String name, {
+ required Iterable assetIds,
+ String? description,
+ }) async {
+ final responseDto = await checkNull(
+ _api.createAlbum(
+ CreateAlbumDto(
+ albumName: name,
+ description: description,
+ assetIds: assetIds.toList(),
+ ),
+ ),
+ );
+
+ return responseDto.toRemoteAlbum();
+ }
+
+ Future<({List removed, List failed})> removeAssets(
+ String albumId,
+ Iterable assetIds,
+ ) async {
+ final response = await checkNull(
+ _api.removeAssetFromAlbum(
+ albumId,
+ BulkIdsDto(ids: assetIds.toList()),
+ ),
+ );
+ final List removed = [], failed = [];
+ for (final dto in response) {
+ if (dto.success) {
+ removed.add(dto.id);
+ } else {
+ failed.add(dto.id);
+ }
+ }
+ return (removed: removed, failed: failed);
+ }
+}
+
+extension on AlbumResponseDto {
+ RemoteAlbum toRemoteAlbum() {
+ return RemoteAlbum(
+ id: id,
+ name: albumName,
+ ownerId: owner.id,
+ description: description,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ thumbnailAssetId: albumThumbnailAssetId,
+ isActivityEnabled: isActivityEnabled,
+ order:
+ order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
+ assetCount: assetCount,
+ ownerName: owner.name,
+ );
+ }
+}
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index 18aa937a9d..5fcd060b0c 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -1,6 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/album/album.model.dart';
+import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
@@ -67,22 +69,23 @@ import 'package:immich_mobile/pages/search/person_result.page.dart';
import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_favorite.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_partner_detail.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_local_album.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_recently_taken.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_video.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_trash.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_archive.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/drift_locked_folder.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart';
+import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
-import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
+import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -387,7 +390,7 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
- page: RemoteTimelineRoute.page,
+ page: RemoteAlbumRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
@@ -446,6 +449,11 @@ class AppRouter extends RootStackRouter {
page: DriftLocalAlbumsRoute.page,
guards: [_authGuard, _duplicateGuard],
),
+ AutoRoute(
+ page: DriftCreateAlbumRoute.page,
+ guards: [_authGuard, _duplicateGuard],
+ ),
+
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index b13320b1c0..bd2b148455 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -683,6 +683,22 @@ class DriftAssetSelectionTimelineRouteArgs {
}
}
+/// generated route for
+/// [DriftCreateAlbumPage]
+class DriftCreateAlbumRoute extends PageRouteInfo {
+ const DriftCreateAlbumRoute({List? children})
+ : super(DriftCreateAlbumRoute.name, initialChildren: children);
+
+ static const String name = 'DriftCreateAlbumRoute';
+
+ static PageInfo page = PageInfo(
+ name,
+ builder: (data) {
+ return const DriftCreateAlbumPage();
+ },
+ );
+}
+
/// generated route for
/// [DriftFavoritePage]
class DriftFavoriteRoute extends PageRouteInfo {
@@ -805,11 +821,11 @@ class DriftPartnerDetailRoute
extends PageRouteInfo {
DriftPartnerDetailRoute({
Key? key,
- required String partnerId,
+ required UserDto partner,
List? children,
}) : super(
DriftPartnerDetailRoute.name,
- args: DriftPartnerDetailRouteArgs(key: key, partnerId: partnerId),
+ args: DriftPartnerDetailRouteArgs(key: key, partner: partner),
initialChildren: children,
);
@@ -819,21 +835,21 @@ class DriftPartnerDetailRoute
name,
builder: (data) {
final args = data.argsAs();
- return DriftPartnerDetailPage(key: args.key, partnerId: args.partnerId);
+ return DriftPartnerDetailPage(key: args.key, partner: args.partner);
},
);
}
class DriftPartnerDetailRouteArgs {
- const DriftPartnerDetailRouteArgs({this.key, required this.partnerId});
+ const DriftPartnerDetailRouteArgs({this.key, required this.partner});
final Key? key;
- final String partnerId;
+ final UserDto partner;
@override
String toString() {
- return 'DriftPartnerDetailRouteArgs{key: $key, partnerId: $partnerId}';
+ return 'DriftPartnerDetailRouteArgs{key: $key, partner: $partner}';
}
}
@@ -1211,11 +1227,11 @@ class LocalMediaSummaryRoute extends PageRouteInfo {
class LocalTimelineRoute extends PageRouteInfo {
LocalTimelineRoute({
Key? key,
- required String albumId,
+ required LocalAlbum album,
List? children,
}) : super(
LocalTimelineRoute.name,
- args: LocalTimelineRouteArgs(key: key, albumId: albumId),
+ args: LocalTimelineRouteArgs(key: key, album: album),
initialChildren: children,
);
@@ -1225,21 +1241,21 @@ class LocalTimelineRoute extends PageRouteInfo {
name,
builder: (data) {
final args = data.argsAs();
- return LocalTimelinePage(key: args.key, albumId: args.albumId);
+ return LocalTimelinePage(key: args.key, album: args.album);
},
);
}
class LocalTimelineRouteArgs {
- const LocalTimelineRouteArgs({this.key, required this.albumId});
+ const LocalTimelineRouteArgs({this.key, required this.album});
final Key? key;
- final String albumId;
+ final LocalAlbum album;
@override
String toString() {
- return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}';
+ return 'LocalTimelineRouteArgs{key: $key, album: $album}';
}
}
@@ -1744,6 +1760,43 @@ class RecentlyTakenRoute extends PageRouteInfo {
);
}
+/// generated route for
+/// [RemoteAlbumPage]
+class RemoteAlbumRoute extends PageRouteInfo {
+ RemoteAlbumRoute({
+ Key? key,
+ required RemoteAlbum album,
+ List? children,
+ }) : super(
+ RemoteAlbumRoute.name,
+ args: RemoteAlbumRouteArgs(key: key, album: album),
+ initialChildren: children,
+ );
+
+ static const String name = 'RemoteAlbumRoute';
+
+ static PageInfo page = PageInfo(
+ name,
+ builder: (data) {
+ final args = data.argsAs();
+ return RemoteAlbumPage(key: args.key, album: args.album);
+ },
+ );
+}
+
+class RemoteAlbumRouteArgs {
+ const RemoteAlbumRouteArgs({this.key, required this.album});
+
+ final Key? key;
+
+ final RemoteAlbum album;
+
+ @override
+ String toString() {
+ return 'RemoteAlbumRouteArgs{key: $key, album: $album}';
+ }
+}
+
/// generated route for
/// [RemoteMediaSummaryPage]
class RemoteMediaSummaryRoute extends PageRouteInfo {
@@ -1760,43 +1813,6 @@ class RemoteMediaSummaryRoute extends PageRouteInfo {
);
}
-/// generated route for
-/// [RemoteTimelinePage]
-class RemoteTimelineRoute extends PageRouteInfo {
- RemoteTimelineRoute({
- Key? key,
- required String albumId,
- List? children,
- }) : super(
- RemoteTimelineRoute.name,
- args: RemoteTimelineRouteArgs(key: key, albumId: albumId),
- initialChildren: children,
- );
-
- static const String name = 'RemoteTimelineRoute';
-
- static PageInfo page = PageInfo(
- name,
- builder: (data) {
- final args = data.argsAs();
- return RemoteTimelinePage(key: args.key, albumId: args.albumId);
- },
- );
-}
-
-class RemoteTimelineRouteArgs {
- const RemoteTimelineRouteArgs({this.key, required this.albumId});
-
- final Key? key;
-
- final String albumId;
-
- @override
- String toString() {
- return 'RemoteTimelineRouteArgs{key: $key, albumId: $albumId}';
- }
-}
-
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo {
diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart
index 2f4c8cc926..d7c625b981 100644
--- a/mobile/lib/services/action.service.dart
+++ b/mobile/lib/services/action.service.dart
@@ -2,9 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
+import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
+import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -14,16 +17,22 @@ final actionServiceProvider = Provider(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
+ ref.watch(driftAlbumApiRepositoryProvider),
+ ref.watch(remoteAlbumRepository),
),
);
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
+ final DriftAlbumApiRepository _albumApiRepository;
+ final DriftRemoteAlbumRepository _remoteAlbumRepository;
const ActionService(
this._assetApiRepository,
this._remoteAssetRepository,
+ this._albumApiRepository,
+ this._remoteAlbumRepository,
);
Future shareLink(List remoteIds, BuildContext context) async {
@@ -131,4 +140,16 @@ class ActionService {
return true;
}
+
+ Future removeFromAlbum(List remoteIds, String albumId) async {
+ int removedCount = 0;
+ final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
+
+ if (result.removed.isNotEmpty) {
+ removedCount =
+ await _remoteAlbumRepository.removeAssets(albumId, result.removed);
+ }
+
+ return removedCount;
+ }
}
diff --git a/mobile/lib/utils/remote_album.utils.dart b/mobile/lib/utils/remote_album.utils.dart
index 4fc7ba5f74..04184ee367 100644
--- a/mobile/lib/utils/remote_album.utils.dart
+++ b/mobile/lib/utils/remote_album.utils.dart
@@ -1,38 +1,56 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
-typedef AlbumSortFn = List Function(List albums, bool isReverse);
+typedef AlbumSortFn = List Function(
+ List albums,
+ bool isReverse,
+);
class _RemoteAlbumSortHandlers {
const _RemoteAlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated;
- static List _sortByCreated(List albums, bool isReverse) {
+ static List _sortByCreated(
+ List albums,
+ bool isReverse,
+ ) {
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn title = _sortByTitle;
- static List _sortByTitle(List albums, bool isReverse) {
+ static List _sortByTitle(
+ List albums,
+ bool isReverse,
+ ) {
final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn lastModified = _sortByLastModified;
- static List _sortByLastModified(List albums, bool isReverse) {
+ static List _sortByLastModified(
+ List albums,
+ bool isReverse,
+ ) {
final sorted = albums.sortedBy((album) => album.updatedAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn assetCount = _sortByAssetCount;
- static List _sortByAssetCount(List albums, bool isReverse) {
+ static List _sortByAssetCount(
+ List albums,
+ bool isReverse,
+ ) {
final sorted =
albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostRecent = _sortByMostRecent;
- static List _sortByMostRecent(List albums, bool isReverse) {
+ static List _sortByMostRecent(
+ List albums,
+ bool isReverse,
+ ) {
final sorted = albums.sorted((a, b) {
// For most recent, we sort by updatedAt in descending order
return b.updatedAt.compareTo(a.updatedAt);
@@ -41,7 +59,10 @@ class _RemoteAlbumSortHandlers {
}
static const AlbumSortFn mostOldest = _sortByMostOldest;
- static List _sortByMostOldest(List albums, bool isReverse) {
+ static List _sortByMostOldest(
+ List albums,
+ bool isReverse,
+ ) {
final sorted = albums.sorted((a, b) {
// For oldest, we sort by createdAt in ascending order
return a.createdAt.compareTo(b.createdAt);
diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart
index dbf088c112..799cf17f3f 100644
--- a/mobile/lib/widgets/common/immich_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_app_bar.dart
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -185,7 +186,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: action,
),
),
- if (kDebugMode || kProfileMode)
+ if (kDebugMode || kProfileMode || appFlavor == 'beta')
IconButton(
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
index ff0e88e5d7..c2c5b79753 100644
--- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
@@ -73,7 +73,15 @@ class ImmichSliverAppBar extends ConsumerWidget {
onPressed: () => context.pop(),
),
IconButton(
- onPressed: () => ref.read(backgroundSyncProvider).syncRemote(),
+ onPressed: () {
+ ref.read(backgroundSyncProvider).syncLocal(full: true);
+ ref.read(backgroundSyncProvider).syncRemote();
+
+ Future.delayed(
+ const Duration(seconds: 10),
+ () => ref.read(backgroundSyncProvider).hashAssets(),
+ );
+ },
icon: const Icon(
Icons.sync,
),
diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart
new file mode 100644
index 0000000000..36f944dbcd
--- /dev/null
+++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart
@@ -0,0 +1,563 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/services/timeline.service.dart';
+import 'package:immich_mobile/domain/utils/event_stream.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/extensions/translate_extensions.dart';
+import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
+import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
+import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
+
+class MesmerizingSliverAppBar extends ConsumerStatefulWidget {
+ const MesmerizingSliverAppBar({
+ super.key,
+ required this.title,
+ this.icon = Icons.camera,
+ });
+
+ final String title;
+ final IconData icon;
+
+ @override
+ ConsumerState createState() =>
+ _MesmerizingSliverAppBarState();
+}
+
+class _MesmerizingSliverAppBarState
+ extends ConsumerState {
+ double _scrollProgress = 0.0;
+
+ double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
+ if (settings?.maxExtent == null || settings?.minExtent == null) {
+ return 1.0;
+ }
+
+ final deltaExtent = settings!.maxExtent - settings.minExtent;
+ if (deltaExtent <= 0.0) {
+ return 1.0;
+ }
+
+ return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent)
+ .clamp(0.0, 1.0);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isMultiSelectEnabled =
+ ref.watch(multiSelectProvider.select((s) => s.isEnabled));
+
+ return isMultiSelectEnabled
+ ? SliverToBoxAdapter(
+ child: switch (_scrollProgress) {
+ < 0.8 => const SizedBox(height: 120),
+ _ => const SizedBox(height: 352),
+ },
+ )
+ : SliverAppBar(
+ expandedHeight: 300.0,
+ floating: false,
+ pinned: true,
+ snap: false,
+ elevation: 0,
+ leading: IconButton(
+ icon: Icon(
+ Platform.isIOS
+ ? Icons.arrow_back_ios_new_rounded
+ : Icons.arrow_back,
+ color: Color.lerp(
+ Colors.white,
+ context.primaryColor,
+ _scrollProgress,
+ ),
+ shadows: [
+ _scrollProgress < 0.95
+ ? Shadow(
+ offset: const Offset(0, 2),
+ blurRadius: 5,
+ color: Colors.black.withValues(alpha: 0.5),
+ )
+ : const Shadow(
+ offset: Offset(0, 2),
+ blurRadius: 0,
+ color: Colors.transparent,
+ ),
+ ],
+ ),
+ onPressed: () {
+ context.pop();
+ },
+ ),
+ flexibleSpace: Builder(
+ builder: (context) {
+ final settings = context.dependOnInheritedWidgetOfExactType<
+ FlexibleSpaceBarSettings>();
+ final scrollProgress = _calculateScrollProgress(settings);
+
+ // Update scroll progress for the leading button
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted && _scrollProgress != scrollProgress) {
+ setState(() {
+ _scrollProgress = scrollProgress;
+ });
+ }
+ });
+
+ return FlexibleSpaceBar(
+ centerTitle: true,
+ title: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 200),
+ child: scrollProgress > 0.95
+ ? Text(
+ widget.title,
+ style: TextStyle(
+ color: context.primaryColor,
+ fontWeight: FontWeight.w600,
+ fontSize: 18,
+ ),
+ )
+ : null,
+ ),
+ background: _ExpandedBackground(
+ scrollProgress: scrollProgress,
+ title: widget.title,
+ icon: widget.icon,
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _ExpandedBackground extends ConsumerStatefulWidget {
+ final double scrollProgress;
+ final String title;
+ final IconData icon;
+
+ const _ExpandedBackground({
+ required this.scrollProgress,
+ required this.title,
+ required this.icon,
+ });
+
+ @override
+ ConsumerState<_ExpandedBackground> createState() =>
+ _ExpandedBackgroundState();
+}
+
+class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _slideController;
+ late Animation _slideAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _slideController = AnimationController(
+ duration: const Duration(milliseconds: 800),
+ vsync: this,
+ );
+
+ _slideAnimation = Tween(
+ begin: const Offset(0, 1.5),
+ end: Offset.zero,
+ ).animate(
+ CurvedAnimation(
+ parent: _slideController,
+ curve: Curves.easeOutCubic,
+ ),
+ );
+
+ Future.delayed(const Duration(milliseconds: 100), () {
+ if (mounted) {
+ _slideController.forward();
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _slideController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final timelineService = ref.watch(timelineServiceProvider);
+
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Transform.translate(
+ offset: Offset(0, widget.scrollProgress * 50),
+ child: Transform.scale(
+ scale: 1.4 - (widget.scrollProgress * 0.2),
+ child: _RandomAssetBackground(
+ timelineService: timelineService,
+ icon: widget.icon,
+ ),
+ ),
+ ),
+ Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.transparent,
+ Colors.transparent,
+ Colors.black.withValues(
+ alpha: 0.6 + (widget.scrollProgress * 0.2),
+ ),
+ ],
+ stops: const [0.0, 0.65, 1.0],
+ ),
+ ),
+ ),
+ Positioned(
+ bottom: 16,
+ left: 16,
+ right: 16,
+ child: SlideTransition(
+ position: _slideAnimation,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: double.infinity,
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Text(
+ widget.title,
+ maxLines: 1,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 36,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 0.5,
+ shadows: [
+ Shadow(
+ offset: Offset(0, 2),
+ blurRadius: 12,
+ color: Colors.black45,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ AnimatedContainer(
+ duration: const Duration(milliseconds: 300),
+ child: const _ItemCountText(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _ItemCountText extends ConsumerStatefulWidget {
+ const _ItemCountText();
+
+ @override
+ ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
+}
+
+class _ItemCountTextState extends ConsumerState<_ItemCountText> {
+ StreamSubscription? _reloadSubscription;
+
+ @override
+ void initState() {
+ super.initState();
+ _reloadSubscription =
+ EventStream.shared.listen((_) => setState(() {}));
+ }
+
+ @override
+ void dispose() {
+ _reloadSubscription?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final assetCount = ref.watch(
+ timelineServiceProvider.select((s) => s.totalAssets),
+ );
+
+ return Text(
+ 'items_count'.t(
+ context: context,
+ args: {"count": assetCount},
+ ),
+ style: context.textTheme.labelLarge?.copyWith(
+ // letterSpacing: 0.2,
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ shadows: [
+ const Shadow(
+ offset: Offset(0, 1),
+ blurRadius: 6,
+ color: Colors.black45,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _RandomAssetBackground extends StatefulWidget {
+ final TimelineService timelineService;
+ final IconData icon;
+
+ const _RandomAssetBackground({
+ required this.timelineService,
+ required this.icon,
+ });
+
+ @override
+ State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
+}
+
+class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
+ with TickerProviderStateMixin {
+ late AnimationController _zoomController;
+ late AnimationController _crossFadeController;
+ late Animation _zoomAnimation;
+ late Animation _panAnimation;
+ late Animation _crossFadeAnimation;
+ BaseAsset? _currentAsset;
+ BaseAsset? _nextAsset;
+ bool _isZoomingIn = true;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _zoomController = AnimationController(
+ duration: const Duration(seconds: 12),
+ vsync: this,
+ );
+
+ _crossFadeController = AnimationController(
+ duration: const Duration(milliseconds: 1200),
+ vsync: this,
+ );
+
+ _zoomAnimation = Tween(
+ begin: 1.0,
+ end: 1.2,
+ ).animate(
+ CurvedAnimation(
+ parent: _zoomController,
+ curve: Curves.easeInOut,
+ ),
+ );
+
+ _panAnimation = Tween(
+ begin: Offset.zero,
+ end: const Offset(0.5, -0.5),
+ ).animate(
+ CurvedAnimation(
+ parent: _zoomController,
+ curve: Curves.easeInOut,
+ ),
+ );
+
+ _crossFadeAnimation = Tween(
+ begin: 0.0,
+ end: 1.0,
+ ).animate(
+ CurvedAnimation(
+ parent: _crossFadeController,
+ curve: Curves.easeInOutCubic,
+ ),
+ );
+
+ Future.delayed(
+ Durations.medium1,
+ () => _loadFirstAsset(),
+ );
+ }
+
+ @override
+ void dispose() {
+ _zoomController.dispose();
+ _crossFadeController.dispose();
+ super.dispose();
+ }
+
+ void _startAnimationCycle() {
+ if (_isZoomingIn) {
+ _zoomController.forward().then((_) {
+ _loadNextAsset();
+ });
+ } else {
+ _zoomController.reverse().then((_) {
+ _loadNextAsset();
+ });
+ }
+ }
+
+ Future _loadFirstAsset() async {
+ if (!mounted) {
+ return;
+ }
+
+ if (widget.timelineService.totalAssets == 0) {
+ setState(() {
+ _currentAsset = null;
+ });
+
+ return;
+ }
+
+ setState(() {
+ _currentAsset = widget.timelineService.getRandomAsset();
+ });
+
+ await _crossFadeController.forward();
+
+ if (_zoomController.status == AnimationStatus.dismissed) {
+ if (_isZoomingIn) {
+ _zoomController.reset();
+ } else {
+ _zoomController.value = 1.0;
+ }
+ _startAnimationCycle();
+ }
+ }
+
+ Future _loadNextAsset() async {
+ if (!mounted) {
+ return;
+ }
+
+ try {
+ if (widget.timelineService.totalAssets > 1) {
+ // Load next asset while keeping current one visible
+ final nextAsset = widget.timelineService.getRandomAsset();
+
+ setState(() {
+ _nextAsset = nextAsset;
+ });
+
+ await _crossFadeController.reverse();
+ setState(() {
+ _currentAsset = _nextAsset;
+ _nextAsset = null;
+ });
+
+ _crossFadeController.value = 1.0;
+
+ _isZoomingIn = !_isZoomingIn;
+
+ _startAnimationCycle();
+ }
+ } catch (e) {
+ _zoomController.reset();
+ _startAnimationCycle();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.timelineService.totalAssets == 0) {
+ return const SizedBox.shrink();
+ }
+
+ return AnimatedBuilder(
+ animation: Listenable.merge(
+ [_zoomAnimation, _panAnimation, _crossFadeAnimation],
+ ),
+ builder: (context, child) {
+ return Transform.scale(
+ scale: _zoomAnimation.value,
+ filterQuality: FilterQuality.low,
+ child: Transform.translate(
+ offset: _panAnimation.value,
+ filterQuality: FilterQuality.low,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ // Current image
+ if (_currentAsset != null)
+ Opacity(
+ opacity: _crossFadeAnimation.value,
+ child: SizedBox(
+ width: double.infinity,
+ height: double.infinity,
+ child: Image(
+ alignment: Alignment.topRight,
+ image: getFullImageProvider(_currentAsset!),
+ fit: BoxFit.cover,
+ frameBuilder:
+ (context, child, frame, wasSynchronouslyLoaded) {
+ if (wasSynchronouslyLoaded || frame != null) {
+ return child;
+ }
+ return Container();
+ },
+ errorBuilder: (context, error, stackTrace) {
+ return SizedBox(
+ width: double.infinity,
+ height: double.infinity,
+ child: Icon(
+ Icons.error_outline_rounded,
+ size: 24,
+ color: Colors.red[300],
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+
+ if (_nextAsset != null)
+ Opacity(
+ opacity: 1.0 - _crossFadeAnimation.value,
+ child: SizedBox(
+ width: double.infinity,
+ height: double.infinity,
+ child: Image(
+ alignment: Alignment.topRight,
+ image: getFullImageProvider(_nextAsset!),
+ fit: BoxFit.cover,
+ frameBuilder:
+ (context, child, frame, wasSynchronouslyLoaded) {
+ if (wasSynchronouslyLoaded || frame != null) {
+ return child;
+ }
+ return const SizedBox.shrink();
+ },
+ errorBuilder: (context, error, stackTrace) {
+ return SizedBox(
+ width: double.infinity,
+ height: double.infinity,
+ child: Icon(
+ Icons.error_outline_rounded,
+ size: 24,
+ color: Colors.red[300],
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/mobile/makefile b/mobile/makefile
index 64992ec946..37d33fa817 100644
--- a/mobile/makefile
+++ b/mobile/makefile
@@ -28,4 +28,7 @@ translation:
dart run easy_localization:generate -S ../i18n
dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart
- dart format lib/generated/intl_keys.g.dart
\ No newline at end of file
+ dart format lib/generated/intl_keys.g.dart
+
+build-beta:
+ flutter build apk --flavor beta --release
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 1beaec0ae6..28fa63ba84 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -208,6 +208,7 @@ Class | Method | HTTP request | Description
*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} |
*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions |
*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock |
+*SessionsApi* | [**updateSession**](doc//SessionsApi.md#updatesession) | **PUT** /sessions/{id} |
*SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets |
*SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links |
*SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links |
@@ -449,6 +450,7 @@ Class | Method | HTTP request | Description
- [SessionCreateResponseDto](doc//SessionCreateResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SessionUnlockDto](doc//SessionUnlockDto.md)
+ - [SessionUpdateDto](doc//SessionUpdateDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
@@ -481,11 +483,15 @@ Class | Method | HTTP request | Description
- [SyncMemoryV1](doc//SyncMemoryV1.md)
- [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md)
- [SyncPartnerV1](doc//SyncPartnerV1.md)
+ - [SyncPersonDeleteV1](doc//SyncPersonDeleteV1.md)
+ - [SyncPersonV1](doc//SyncPersonV1.md)
- [SyncRequestType](doc//SyncRequestType.md)
- [SyncStackDeleteV1](doc//SyncStackDeleteV1.md)
- [SyncStackV1](doc//SyncStackV1.md)
- [SyncStreamDto](doc//SyncStreamDto.md)
- [SyncUserDeleteV1](doc//SyncUserDeleteV1.md)
+ - [SyncUserMetadataDeleteV1](doc//SyncUserMetadataDeleteV1.md)
+ - [SyncUserMetadataV1](doc//SyncUserMetadataV1.md)
- [SyncUserV1](doc//SyncUserV1.md)
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- [SystemConfigDto](doc//SystemConfigDto.md)
@@ -503,6 +509,7 @@ Class | Method | HTTP request | Description
- [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- [SystemConfigMetadataDto](doc//SystemConfigMetadataDto.md)
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
+ - [SystemConfigNightlyTasksDto](doc//SystemConfigNightlyTasksDto.md)
- [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 3998961720..becafa06bf 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -232,6 +232,7 @@ part 'model/session_create_dto.dart';
part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/session_unlock_dto.dart';
+part 'model/session_update_dto.dart';
part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart';
@@ -264,11 +265,15 @@ part 'model/sync_memory_delete_v1.dart';
part 'model/sync_memory_v1.dart';
part 'model/sync_partner_delete_v1.dart';
part 'model/sync_partner_v1.dart';
+part 'model/sync_person_delete_v1.dart';
+part 'model/sync_person_v1.dart';
part 'model/sync_request_type.dart';
part 'model/sync_stack_delete_v1.dart';
part 'model/sync_stack_v1.dart';
part 'model/sync_stream_dto.dart';
part 'model/sync_user_delete_v1.dart';
+part 'model/sync_user_metadata_delete_v1.dart';
+part 'model/sync_user_metadata_v1.dart';
part 'model/sync_user_v1.dart';
part 'model/system_config_backups_dto.dart';
part 'model/system_config_dto.dart';
@@ -286,6 +291,7 @@ part 'model/system_config_machine_learning_dto.dart';
part 'model/system_config_map_dto.dart';
part 'model/system_config_metadata_dto.dart';
part 'model/system_config_new_version_check_dto.dart';
+part 'model/system_config_nightly_tasks_dto.dart';
part 'model/system_config_notifications_dto.dart';
part 'model/system_config_o_auth_dto.dart';
part 'model/system_config_password_login_dto.dart';
diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart
index 3228d31e91..d54f520641 100644
--- a/mobile/openapi/lib/api/sessions_api.dart
+++ b/mobile/openapi/lib/api/sessions_api.dart
@@ -219,4 +219,56 @@ class SessionsApi {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
+
+ /// Performs an HTTP 'PUT /sessions/{id}' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ ///
+ /// * [SessionUpdateDto] sessionUpdateDto (required):
+ Future updateSessionWithHttpInfo(String id, SessionUpdateDto sessionUpdateDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/sessions/{id}'
+ .replaceAll('{id}', id);
+
+ // ignore: prefer_final_locals
+ Object? postBody = sessionUpdateDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'PUT',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] id (required):
+ ///
+ /// * [SessionUpdateDto] sessionUpdateDto (required):
+ Future updateSession(String id, SessionUpdateDto sessionUpdateDto,) async {
+ final response = await updateSessionWithHttpInfo(id, sessionUpdateDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SessionResponseDto',) as SessionResponseDto;
+
+ }
+ return null;
+ }
}
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 0edc2638bc..603163f00e 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -520,6 +520,8 @@ class ApiClient {
return SessionResponseDto.fromJson(value);
case 'SessionUnlockDto':
return SessionUnlockDto.fromJson(value);
+ case 'SessionUpdateDto':
+ return SessionUpdateDto.fromJson(value);
case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':
@@ -584,6 +586,10 @@ class ApiClient {
return SyncPartnerDeleteV1.fromJson(value);
case 'SyncPartnerV1':
return SyncPartnerV1.fromJson(value);
+ case 'SyncPersonDeleteV1':
+ return SyncPersonDeleteV1.fromJson(value);
+ case 'SyncPersonV1':
+ return SyncPersonV1.fromJson(value);
case 'SyncRequestType':
return SyncRequestTypeTypeTransformer().decode(value);
case 'SyncStackDeleteV1':
@@ -594,6 +600,10 @@ class ApiClient {
return SyncStreamDto.fromJson(value);
case 'SyncUserDeleteV1':
return SyncUserDeleteV1.fromJson(value);
+ case 'SyncUserMetadataDeleteV1':
+ return SyncUserMetadataDeleteV1.fromJson(value);
+ case 'SyncUserMetadataV1':
+ return SyncUserMetadataV1.fromJson(value);
case 'SyncUserV1':
return SyncUserV1.fromJson(value);
case 'SystemConfigBackupsDto':
@@ -628,6 +638,8 @@ class ApiClient {
return SystemConfigMetadataDto.fromJson(value);
case 'SystemConfigNewVersionCheckDto':
return SystemConfigNewVersionCheckDto.fromJson(value);
+ case 'SystemConfigNightlyTasksDto':
+ return SystemConfigNightlyTasksDto.fromJson(value);
case 'SystemConfigNotificationsDto':
return SystemConfigNotificationsDto.fromJson(value);
case 'SystemConfigOAuthDto':
diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart
index ab1c4ca2d8..a4f93e8d9c 100644
--- a/mobile/openapi/lib/model/session_create_response_dto.dart
+++ b/mobile/openapi/lib/model/session_create_response_dto.dart
@@ -19,6 +19,7 @@ class SessionCreateResponseDto {
required this.deviceType,
this.expiresAt,
required this.id,
+ required this.isPendingSyncReset,
required this.token,
required this.updatedAt,
});
@@ -41,6 +42,8 @@ class SessionCreateResponseDto {
String id;
+ bool isPendingSyncReset;
+
String token;
String updatedAt;
@@ -53,6 +56,7 @@ class SessionCreateResponseDto {
other.deviceType == deviceType &&
other.expiresAt == expiresAt &&
other.id == id &&
+ other.isPendingSyncReset == isPendingSyncReset &&
other.token == token &&
other.updatedAt == updatedAt;
@@ -65,11 +69,12 @@ class SessionCreateResponseDto {
(deviceType.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(id.hashCode) +
+ (isPendingSyncReset.hashCode) +
(token.hashCode) +
(updatedAt.hashCode);
@override
- String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]';
+ String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]';
Map toJson() {
final json = {};
@@ -83,6 +88,7 @@ class SessionCreateResponseDto {
// json[r'expiresAt'] = null;
}
json[r'id'] = this.id;
+ json[r'isPendingSyncReset'] = this.isPendingSyncReset;
json[r'token'] = this.token;
json[r'updatedAt'] = this.updatedAt;
return json;
@@ -103,6 +109,7 @@ class SessionCreateResponseDto {
deviceType: mapValueOfType(json, r'deviceType')!,
expiresAt: mapValueOfType(json, r'expiresAt'),
id: mapValueOfType(json, r'id')!,
+ isPendingSyncReset: mapValueOfType(json, r'isPendingSyncReset')!,
token: mapValueOfType(json, r'token')!,
updatedAt: mapValueOfType(json, r'updatedAt')!,
);
@@ -157,6 +164,7 @@ class SessionCreateResponseDto {
'deviceOS',
'deviceType',
'id',
+ 'isPendingSyncReset',
'token',
'updatedAt',
};
diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart
index cf9eb08a78..e76e4d48b4 100644
--- a/mobile/openapi/lib/model/session_response_dto.dart
+++ b/mobile/openapi/lib/model/session_response_dto.dart
@@ -19,6 +19,7 @@ class SessionResponseDto {
required this.deviceType,
this.expiresAt,
required this.id,
+ required this.isPendingSyncReset,
required this.updatedAt,
});
@@ -40,6 +41,8 @@ class SessionResponseDto {
String id;
+ bool isPendingSyncReset;
+
String updatedAt;
@override
@@ -50,6 +53,7 @@ class SessionResponseDto {
other.deviceType == deviceType &&
other.expiresAt == expiresAt &&
other.id == id &&
+ other.isPendingSyncReset == isPendingSyncReset &&
other.updatedAt == updatedAt;
@override
@@ -61,10 +65,11 @@ class SessionResponseDto {
(deviceType.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(id.hashCode) +
+ (isPendingSyncReset.hashCode) +
(updatedAt.hashCode);
@override
- String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, updatedAt=$updatedAt]';
+ String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]';
Map toJson() {
final json = {};
@@ -78,6 +83,7 @@ class SessionResponseDto {
// json[r'expiresAt'] = null;
}
json[r'id'] = this.id;
+ json[r'isPendingSyncReset'] = this.isPendingSyncReset;
json[r'updatedAt'] = this.updatedAt;
return json;
}
@@ -97,6 +103,7 @@ class SessionResponseDto {
deviceType: mapValueOfType(json, r'deviceType')!,
expiresAt: mapValueOfType(json, r'expiresAt'),
id: mapValueOfType(json, r'id')!,
+ isPendingSyncReset: mapValueOfType(json, r'isPendingSyncReset')!,
updatedAt: mapValueOfType(json, r'updatedAt')!,
);
}
@@ -150,6 +157,7 @@ class SessionResponseDto {
'deviceOS',
'deviceType',
'id',
+ 'isPendingSyncReset',
'updatedAt',
};
}
diff --git a/mobile/openapi/lib/model/session_update_dto.dart b/mobile/openapi/lib/model/session_update_dto.dart
new file mode 100644
index 0000000000..cd170b1baa
--- /dev/null
+++ b/mobile/openapi/lib/model/session_update_dto.dart
@@ -0,0 +1,108 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SessionUpdateDto {
+ /// Returns a new [SessionUpdateDto] instance.
+ SessionUpdateDto({
+ this.isPendingSyncReset,
+ });
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ bool? isPendingSyncReset;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is SessionUpdateDto &&
+ other.isPendingSyncReset == isPendingSyncReset;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (isPendingSyncReset == null ? 0 : isPendingSyncReset!.hashCode);
+
+ @override
+ String toString() => 'SessionUpdateDto[isPendingSyncReset=$isPendingSyncReset]';
+
+ Map toJson() {
+ final json = {};
+ if (this.isPendingSyncReset != null) {
+ json[r'isPendingSyncReset'] = this.isPendingSyncReset;
+ } else {
+ // json[r'isPendingSyncReset'] = null;
+ }
+ return json;
+ }
+
+ /// Returns a new [SessionUpdateDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static SessionUpdateDto? fromJson(dynamic value) {
+ upgradeDto(value, "SessionUpdateDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return SessionUpdateDto(
+ isPendingSyncReset: mapValueOfType(json, r'isPendingSyncReset'),
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = SessionUpdateDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = SessionUpdateDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of SessionUpdateDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = SessionUpdateDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ };
+}
+
diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart
index 1ca6e20cff..4c42d08a5f 100644
--- a/mobile/openapi/lib/model/sync_asset_v1.dart
+++ b/mobile/openapi/lib/model/sync_asset_v1.dart
@@ -20,9 +20,11 @@ class SyncAssetV1 {
required this.fileModifiedAt,
required this.id,
required this.isFavorite,
+ required this.livePhotoVideoId,
required this.localDateTime,
required this.originalFileName,
required this.ownerId,
+ required this.stackId,
required this.thumbhash,
required this.type,
required this.visibility,
@@ -42,12 +44,16 @@ class SyncAssetV1 {
bool isFavorite;
+ String? livePhotoVideoId;
+
DateTime? localDateTime;
String originalFileName;
String ownerId;
+ String? stackId;
+
String? thumbhash;
AssetTypeEnum type;
@@ -63,9 +69,11 @@ class SyncAssetV1 {
other.fileModifiedAt == fileModifiedAt &&
other.id == id &&
other.isFavorite == isFavorite &&
+ other.livePhotoVideoId == livePhotoVideoId &&
other.localDateTime == localDateTime &&
other.originalFileName == originalFileName &&
other.ownerId == ownerId &&
+ other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility;
@@ -80,15 +88,17 @@ class SyncAssetV1 {
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(id.hashCode) +
(isFavorite.hashCode) +
+ (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(localDateTime == null ? 0 : localDateTime!.hashCode) +
(originalFileName.hashCode) +
(ownerId.hashCode) +
+ (stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode);
@override
- String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
+ String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
Map toJson() {
final json = {};
@@ -115,6 +125,11 @@ class SyncAssetV1 {
}
json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite;
+ if (this.livePhotoVideoId != null) {
+ json[r'livePhotoVideoId'] = this.livePhotoVideoId;
+ } else {
+ // json[r'livePhotoVideoId'] = null;
+ }
if (this.localDateTime != null) {
json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String();
} else {
@@ -122,6 +137,11 @@ class SyncAssetV1 {
}
json[r'originalFileName'] = this.originalFileName;
json[r'ownerId'] = this.ownerId;
+ if (this.stackId != null) {
+ json[r'stackId'] = this.stackId;
+ } else {
+ // json[r'stackId'] = null;
+ }
if (this.thumbhash != null) {
json[r'thumbhash'] = this.thumbhash;
} else {
@@ -148,9 +168,11 @@ class SyncAssetV1 {
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
id: mapValueOfType(json, r'id')!,
isFavorite: mapValueOfType(json, r'isFavorite')!,
+ livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'),
localDateTime: mapDateTime(json, r'localDateTime', r''),
originalFileName: mapValueOfType(json, r'originalFileName')!,
ownerId: mapValueOfType(json, r'ownerId')!,
+ stackId: mapValueOfType(json, r'stackId'),
thumbhash: mapValueOfType(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
@@ -208,9 +230,11 @@ class SyncAssetV1 {
'fileModifiedAt',
'id',
'isFavorite',
+ 'livePhotoVideoId',
'localDateTime',
'originalFileName',
'ownerId',
+ 'stackId',
'thumbhash',
'type',
'visibility',
diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart
index ecaadc9c31..61f94401c7 100644
--- a/mobile/openapi/lib/model/sync_entity_type.dart
+++ b/mobile/openapi/lib/model/sync_entity_type.dart
@@ -56,7 +56,12 @@ class SyncEntityType {
static const memoryToAssetDeleteV1 = SyncEntityType._(r'MemoryToAssetDeleteV1');
static const stackV1 = SyncEntityType._(r'StackV1');
static const stackDeleteV1 = SyncEntityType._(r'StackDeleteV1');
+ static const personV1 = SyncEntityType._(r'PersonV1');
+ static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1');
+ static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1');
+ static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1');
static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
+ static const syncResetV1 = SyncEntityType._(r'SyncResetV1');
/// List of all possible values in this [enum][SyncEntityType].
static const values = [
@@ -93,7 +98,12 @@ class SyncEntityType {
memoryToAssetDeleteV1,
stackV1,
stackDeleteV1,
+ personV1,
+ personDeleteV1,
+ userMetadataV1,
+ userMetadataDeleteV1,
syncAckV1,
+ syncResetV1,
];
static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@@ -165,7 +175,12 @@ class SyncEntityTypeTypeTransformer {
case r'MemoryToAssetDeleteV1': return SyncEntityType.memoryToAssetDeleteV1;
case r'StackV1': return SyncEntityType.stackV1;
case r'StackDeleteV1': return SyncEntityType.stackDeleteV1;
+ case r'PersonV1': return SyncEntityType.personV1;
+ case r'PersonDeleteV1': return SyncEntityType.personDeleteV1;
+ case r'UserMetadataV1': return SyncEntityType.userMetadataV1;
+ case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1;
case r'SyncAckV1': return SyncEntityType.syncAckV1;
+ case r'SyncResetV1': return SyncEntityType.syncResetV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
diff --git a/mobile/openapi/lib/model/sync_person_delete_v1.dart b/mobile/openapi/lib/model/sync_person_delete_v1.dart
new file mode 100644
index 0000000000..002f5c5b83
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_person_delete_v1.dart
@@ -0,0 +1,99 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SyncPersonDeleteV1 {
+ /// Returns a new [SyncPersonDeleteV1] instance.
+ SyncPersonDeleteV1({
+ required this.personId,
+ });
+
+ String personId;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is SyncPersonDeleteV1 &&
+ other.personId == personId;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (personId.hashCode);
+
+ @override
+ String toString() => 'SyncPersonDeleteV1[personId=$personId]';
+
+ Map toJson() {
+ final json = {};
+ json[r'personId'] = this.personId;
+ return json;
+ }
+
+ /// Returns a new [SyncPersonDeleteV1] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static SyncPersonDeleteV1? fromJson(dynamic value) {
+ upgradeDto(value, "SyncPersonDeleteV1");
+ if (value is Map) {
+ final json = value.cast();
+
+ return SyncPersonDeleteV1(
+ personId: mapValueOfType(json, r'personId')!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = SyncPersonDeleteV1.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = SyncPersonDeleteV1.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of SyncPersonDeleteV1-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = SyncPersonDeleteV1.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'personId',
+ };
+}
+
diff --git a/mobile/openapi/lib/model/sync_person_v1.dart b/mobile/openapi/lib/model/sync_person_v1.dart
new file mode 100644
index 0000000000..e86c22f64b
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_person_v1.dart
@@ -0,0 +1,191 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SyncPersonV1 {
+ /// Returns a new [SyncPersonV1] instance.
+ SyncPersonV1({
+ required this.birthDate,
+ required this.color,
+ required this.createdAt,
+ required this.faceAssetId,
+ required this.id,
+ required this.isFavorite,
+ required this.isHidden,
+ required this.name,
+ required this.ownerId,
+ required this.thumbnailPath,
+ required this.updatedAt,
+ });
+
+ DateTime? birthDate;
+
+ String? color;
+
+ DateTime createdAt;
+
+ String? faceAssetId;
+
+ String id;
+
+ bool isFavorite;
+
+ bool isHidden;
+
+ String name;
+
+ String ownerId;
+
+ String thumbnailPath;
+
+ DateTime updatedAt;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is SyncPersonV1 &&
+ other.birthDate == birthDate &&
+ other.color == color &&
+ other.createdAt == createdAt &&
+ other.faceAssetId == faceAssetId &&
+ other.id == id &&
+ other.isFavorite == isFavorite &&
+ other.isHidden == isHidden &&
+ other.name == name &&
+ other.ownerId == ownerId &&
+ other.thumbnailPath == thumbnailPath &&
+ other.updatedAt == updatedAt;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (birthDate == null ? 0 : birthDate!.hashCode) +
+ (color == null ? 0 : color!.hashCode) +
+ (createdAt.hashCode) +
+ (faceAssetId == null ? 0 : faceAssetId!.hashCode) +
+ (id.hashCode) +
+ (isFavorite.hashCode) +
+ (isHidden.hashCode) +
+ (name.hashCode) +
+ (ownerId.hashCode) +
+ (thumbnailPath.hashCode) +
+ (updatedAt.hashCode);
+
+ @override
+ String toString() => 'SyncPersonV1[birthDate=$birthDate, color=$color, createdAt=$createdAt, faceAssetId=$faceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, ownerId=$ownerId, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
+
+ Map toJson() {
+ final json = {};
+ if (this.birthDate != null) {
+ json[r'birthDate'] = this.birthDate!.toUtc().toIso8601String();
+ } else {
+ // json[r'birthDate'] = null;
+ }
+ if (this.color != null) {
+ json[r'color'] = this.color;
+ } else {
+ // json[r'color'] = null;
+ }
+ json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
+ if (this.faceAssetId != null) {
+ json[r'faceAssetId'] = this.faceAssetId;
+ } else {
+ // json[r'faceAssetId'] = null;
+ }
+ json[r'id'] = this.id;
+ json[r'isFavorite'] = this.isFavorite;
+ json[r'isHidden'] = this.isHidden;
+ json[r'name'] = this.name;
+ json[r'ownerId'] = this.ownerId;
+ json[r'thumbnailPath'] = this.thumbnailPath;
+ json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
+ return json;
+ }
+
+ /// Returns a new [SyncPersonV1] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static SyncPersonV1? fromJson(dynamic value) {
+ upgradeDto(value, "SyncPersonV1");
+ if (value is Map) {
+ final json = value.cast();
+
+ return SyncPersonV1(
+ birthDate: mapDateTime(json, r'birthDate', r''),
+ color: mapValueOfType(json, r'color'),
+ createdAt: mapDateTime(json, r'createdAt', r'')!,
+ faceAssetId: mapValueOfType(json, r'faceAssetId'),
+ id: mapValueOfType(json, r'id')!,
+ isFavorite: mapValueOfType