Merge branch 'main' into feat/search-filter-album/web

This commit is contained in:
CJPeckover
2025-07-14 13:45:13 -04:00
281 changed files with 9948 additions and 4260 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ install_dependencies() {
( (
cd "${IMMICH_WORKSPACE}" || exit 1 cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1 export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-dev run_cmd make setup-web-dev setup-server-dev
) )
log "" log ""
} }
+4 -4
View File
@@ -122,17 +122,17 @@ jobs:
IS_MAIN: ${{ github.ref == 'refs/heads/main' }} IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: | run: |
if [[ $IS_MAIN == 'true' ]]; then if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release flutter build apk --release --flavor production
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64 flutter build apk --release --flavor production --split-per-abi --target-platform android-arm,android-arm64,android-x64
else 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 fi
- name: Publish Android Artifact - name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: release-apk-signed name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk path: mobile/build/app/outputs/flutter-apk/**/*.apk
- name: Save Gradle Cache - name: Save Gradle Cache
id: cache-gradle-save id: cache-gradle-save
+1
View File
@@ -2,6 +2,7 @@ name: Org Checks
on: on:
pull_request_review: pull_request_review:
pull_request:
jobs: jobs:
check-approvals: check-approvals:
+19
View File
@@ -18,6 +18,25 @@
"name": "Immich Workers", "name": "Immich Workers",
"remoteRoot": "/usr/src/app", "remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server" "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"
],
} }
] ]
} }
+2 -1
View File
@@ -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 ./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 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 setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web
+22 -22
View File
@@ -42,7 +42,7 @@
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^6.0.0", "vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0", "vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0", "vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0", "vitest-fetch-mock": "^0.4.0",
@@ -3466,9 +3466,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3486,7 +3486,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.8", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -4196,24 +4196,24 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.6",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"rollup": "^4.34.9", "rollup": "^4.40.0",
"tinyglobby": "^0.2.13" "tinyglobby": "^0.2.14"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
}, },
"engines": { "engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0" "node": "^20.19.0 || >=22.12.0"
}, },
"funding": { "funding": {
"url": "https://github.com/vitejs/vite?sponsor=1" "url": "https://github.com/vitejs/vite?sponsor=1"
@@ -4222,14 +4222,14 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "*", "less": "^4.0.0",
"lightningcss": "^1.21.0", "lightningcss": "^1.21.0",
"sass": "*", "sass": "^1.70.0",
"sass-embedded": "*", "sass-embedded": "^1.70.0",
"stylus": "*", "stylus": ">=0.54.8",
"sugarss": "*", "sugarss": "^5.0.0",
"terser": "^5.16.0", "terser": "^5.16.0",
"tsx": "^4.8.1", "tsx": "^4.8.1",
"yaml": "^2.4.2" "yaml": "^2.4.2"
@@ -4314,9 +4314,9 @@
} }
}, },
"node_modules/vite/node_modules/fdir": { "node_modules/vite/node_modules/fdir": {
"version": "6.4.4", "version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
+1 -1
View File
@@ -36,7 +36,7 @@
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^6.0.0", "vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0", "vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0", "vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0", "vitest-fetch-mock": "^0.4.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+8 -2
View File
@@ -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. 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.
<img src={require('./img/admin-jobs.webp').default} width="60%" title="Admin jobs" /> <img src={require('./img/admin-jobs.webp').default} width="60%" title="Admin jobs" />
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).
<img src={require('./img/admin-nightly-tasks.webp').default} width="60%" title="Admin nightly tasks" />
:::note
Some jobs ([External Libraries](/docs/features/libraries) scanning, Database Dump) are configured in their own sections in System Settings.
:::
+1 -1
View File
@@ -41,7 +41,7 @@ In the Immich web UI:
- Click Add path - Click Add path
<img src={require('./img/add-path-button.webp').default} width="50%" title="Add Path button" /> <img src={require('./img/add-path-button.webp').default} width="50%" title="Add Path button" />
- Enter **/usr/src/app/external** as the path and click Add - Enter **/home/user/photos1** as the path and click Add
<img src={require('./img/add-path-field.webp').default} width="50%" title="Add Path field" /> <img src={require('./img/add-path-field.webp').default} width="50%" title="Add Path field" />
- Save the new path - Save the new path
+1
View File
@@ -14,6 +14,7 @@
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.15.33", "@types/node": "^22.15.33",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
+1
View File
@@ -24,6 +24,7 @@
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.15.33", "@types/node": "^22.15.33",
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
+32
View File
@@ -7,6 +7,7 @@ import {
ReactionType, ReactionType,
createActivity as create, createActivity as create,
createAlbum, createAlbum,
removeAssetFromAlbum,
} from '@immich/sdk'; } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@@ -342,5 +343,36 @@ describe('/activities', () => {
expect(status).toBe(204); 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([]);
});
}); });
}); });
+1 -1
View File
@@ -20,7 +20,7 @@ describe('/api-keys', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await utils.resetDatabase(['api_keys']); await utils.resetDatabase(['api_key']);
}); });
describe('POST /api-keys', () => { describe('POST /api-keys', () => {
@@ -15,12 +15,6 @@ describe('/system-config', () => {
}); });
describe('PUT /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 () => { it('should always return the new config', async () => {
const config = await getSystemConfig(admin.accessToken); const config = await getSystemConfig(admin.accessToken);
+1 -1
View File
@@ -37,7 +37,7 @@ describe('/tags', () => {
beforeEach(async () => { beforeEach(async () => {
// tagging assets eventually triggers metadata extraction which can impact other tests // tagging assets eventually triggers metadata extraction which can impact other tests
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.resetDatabase(['tags']); await utils.resetDatabase(['tag']);
}); });
describe('POST /tags', () => { describe('POST /tags', () => {
+1 -1
View File
@@ -97,7 +97,7 @@ describe(`immich upload`, () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await utils.resetDatabase(['assets', 'albums']); await utils.resetDatabase(['asset', 'album']);
}); });
describe(`immich upload /path/to/file.jpg`, () => { describe(`immich upload /path/to/file.jpg`, () => {
+1
View File
@@ -116,6 +116,7 @@ export const deviceDto = {
createdAt: expect.any(String), createdAt: expect.any(String),
updatedAt: expect.any(String), updatedAt: expect.any(String),
current: true, current: true,
isPendingSyncReset: false,
deviceOS: '', deviceOS: '',
deviceType: '', deviceType: '',
}, },
+12 -12
View File
@@ -154,19 +154,19 @@ export const utils = {
tables = tables || [ tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex // TODO e2e test for deleting a stack, since it is quite complex
'asset_stack', 'stack',
'libraries', 'library',
'shared_links', 'shared_link',
'person', 'person',
'albums', 'album',
'assets', 'asset',
'asset_faces', 'asset_face',
'activity', 'activity',
'api_keys', 'api_key',
'sessions', 'session',
'users', 'user',
'system_metadata', 'system_metadata',
'tags', 'tag',
]; ];
const sql: string[] = []; const sql: string[] = [];
@@ -175,7 +175,7 @@ export const utils = {
if (table === 'system_metadata') { if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else { } else {
sql.push(`DELETE FROM ${table} CASCADE;`); sql.push(`DELETE FROM "${table}" CASCADE;`);
} }
} }
@@ -451,7 +451,7 @@ export const utils = {
return; 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) => { setPersonThumbnail: async (personId: string) => {
+15
View File
@@ -166,6 +166,20 @@
"metadata_settings_description": "Manage metadata settings", "metadata_settings_description": "Manage metadata settings",
"migration_job": "Migration", "migration_job": "Migration",
"migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure", "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_paths_added": "No paths added",
"no_pattern_added": "No pattern 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", "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_custom_date_range": "Remove custom date range",
"remove_deleted_assets": "Remove Deleted Assets", "remove_deleted_assets": "Remove Deleted Assets",
"remove_from_album": "Remove from album", "remove_from_album": "Remove from album",
"remove_from_album_action_prompt": "{count} removed from the album",
"remove_from_favorites": "Remove from favorites", "remove_from_favorites": "Remove from favorites",
"remove_from_lock_folder_action_prompt": "{count} removed from the locked folder", "remove_from_lock_folder_action_prompt": "{count} removed from the locked folder",
"remove_from_locked_folder": "Remove from locked folder", "remove_from_locked_folder": "Remove from locked folder",
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.29.3" "flutter": "3.32.6"
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"dart.flutterSdkPath": ".fvm/versions/3.29.3", "dart.flutterSdkPath": ".fvm/versions/3.32.6",
"search.exclude": { "search.exclude": {
"**/.fvm": true "**/.fvm": true
}, },
+14
View File
@@ -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 { buildTypes {
debug { debug {
applicationIdSuffix '.debug' applicationIdSuffix '.debug'
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application android:label="Immich Beta" tools:replace="android:label" />
</manifest>
+16 -16
View File
@@ -100,24 +100,24 @@
<!-- my.immich.app deep link --> <!-- my.immich.app deep link -->
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" /> <data android:scheme="https" />
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:path="/" /> android:path="/" />
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/albums/" /> android:pathPrefix="/albums/" />
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/memories/" /> android:pathPrefix="/memories/" />
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/photos/" /> android:pathPrefix="/photos/" />
</intent-filter> </intent-filter>
</activity> </activity>
+25 -25
View File
@@ -5,34 +5,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "80.0.0" version: "82.0.0"
analyzer: analyzer:
dependency: "direct main" dependency: "direct main"
description: description:
name: analyzer name: analyzer
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.3.0" version: "7.4.5"
analyzer_plugin: analyzer_plugin:
dependency: "direct main" dependency: "direct main"
description: description:
name: analyzer_plugin name: analyzer_plugin
sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4 sha256: ee188b6df6c85f1441497c7171c84f1392affadc0384f71089cb10a3bc508cef
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.1"
args: args:
dependency: transitive dependency: transitive
description: description:
name: args name: args
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -53,10 +53,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: checked_yaml name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.4"
ci: ci:
dependency: transitive dependency: transitive
description: description:
@@ -125,10 +125,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: custom_lint_visitor name: custom_lint_visitor
sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8" sha256: cba5b6d7a6217312472bf4468cdf68c949488aed7ffb0eab792cd0b6c435054d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0+7.3.0" version: "1.0.0+7.4.5"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@@ -157,10 +157,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: freezed_annotation name: freezed_annotation
sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.1.0"
glob: glob:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -213,18 +213,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
name: package_config name: package_config
sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -317,10 +317,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.6"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -341,18 +341,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" version: "15.0.2"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
name: watcher name: watcher
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -362,4 +362,4 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.8.0-0 <4.0.0" dart: ">=3.8.0 <4.0.0"
+9 -3
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -117,8 +117,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync; path = Sync;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -473,10 +471,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -505,10 +507,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+5
View File
@@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
@@ -11,7 +11,7 @@ enum AlbumUserRole {
} }
// Model for an album stored in the server // Model for an album stored in the server
class Album { class RemoteAlbum {
final String id; final String id;
final String name; final String name;
final String ownerId; final String ownerId;
@@ -24,7 +24,7 @@ class Album {
final int assetCount; final int assetCount;
final String ownerName; final String ownerName;
const Album({ const RemoteAlbum({
required this.id, required this.id,
required this.name, required this.name,
required this.ownerId, required this.ownerId,
@@ -57,7 +57,7 @@ class Album {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! Album) return false; if (other is! RemoteAlbum) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return id == other.id && return id == other.id &&
name == other.name && name == other.name &&
@@ -1,33 +1,35 @@
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.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/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'; import 'package:immich_mobile/utils/remote_album.utils.dart';
class RemoteAlbumService { class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository; final DriftRemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository;
const RemoteAlbumService(this._repository); const RemoteAlbumService(this._repository, this._albumApiRepository);
Future<List<Album>> getAll() { Future<List<RemoteAlbum>> getAll() {
return _repository.getAll(); return _repository.getAll();
} }
List<Album> sortAlbums( List<RemoteAlbum> sortAlbums(
List<Album> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { RemoteAlbumSortMode sortMode, {
bool isReverse = false, bool isReverse = false,
}) { }) {
return sortMode.sortFn(albums, isReverse); return sortMode.sortFn(albums, isReverse);
} }
List<Album> searchAlbums( List<RemoteAlbum> searchAlbums(
List<Album> albums, List<RemoteAlbum> albums,
String query, String query,
String? userId, [ String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all, QuickFilterMode filterMode = QuickFilterMode.all,
]) { ]) {
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
List<Album> filtered = albums; List<RemoteAlbum> filtered = albums;
// Apply text search filter // Apply text search filter
if (query.isNotEmpty) { if (query.isNotEmpty) {
@@ -57,4 +59,20 @@ class RemoteAlbumService {
return filtered; return filtered;
} }
Future<RemoteAlbum> createAlbum({
required String title,
required List<String> assetIds,
String? description,
}) async {
final album = await _albumApiRepository.createDriftAlbum(
title,
description: description,
assetIds: assetIds,
);
await _repository.create(album, assetIds);
return album;
}
} }
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; 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_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.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:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -23,7 +24,12 @@ class SyncStreamService {
bool get isCancelled => _cancelChecker?.call() ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> sync() => _syncApiRepository.streamChanges(_handleEvents); Future<void> 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<void> _handleEvents(List<SyncEvent> events, Function() abort) async { Future<void> _handleEvents(List<SyncEvent> events, Function() abort) async {
List<SyncEvent> items = []; List<SyncEvent> items = [];
@@ -18,6 +18,11 @@ typedef TimelineAssetSource = Future<List<BaseAsset>> Function(
typedef TimelineBucketSource = Stream<List<Bucket>> Function(); typedef TimelineBucketSource = Stream<List<Bucket>> Function();
typedef TimelineQuery = ({
TimelineAssetSource assetSource,
TimelineBucketSource bucketSource,
});
class TimelineFactory { class TimelineFactory {
final DriftTimelineRepository _timelineRepository; final DriftTimelineRepository _timelineRepository;
final SettingsService _settingsService; final SettingsService _settingsService;
@@ -31,78 +36,32 @@ class TimelineFactory {
GroupAssetsBy get groupBy => GroupAssetsBy get groupBy =>
GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)]; GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)];
TimelineService main(List<String> timelineUsers) => TimelineService( TimelineService main(List<String> timelineUsers) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.main(timelineUsers, groupBy));
.getMainBucketAssets(timelineUsers, offset: offset, count: count),
bucketSource: () => _timelineRepository.watchMainBucket(
timelineUsers,
groupBy: groupBy,
),
);
TimelineService localAlbum({required String albumId}) => TimelineService( TimelineService localAlbum({required String albumId}) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.localAlbum(albumId, groupBy));
.getLocalAlbumBucketAssets(albumId, offset: offset, count: count),
bucketSource: () => _timelineRepository.watchLocalAlbumBucket(
albumId,
groupBy: groupBy,
),
);
TimelineService remoteAlbum({required String albumId}) => TimelineService( TimelineService remoteAlbum({required String albumId}) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.remoteAlbum(albumId, groupBy));
.getRemoteAlbumBucketAssets(albumId, offset: offset, count: count),
bucketSource: () => _timelineRepository.watchRemoteAlbumBucket(
albumId,
groupBy: groupBy,
),
);
TimelineService remoteAssets(String ownerId) => TimelineService( TimelineService remoteAssets(String userId) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.remote(userId, groupBy));
.getRemoteBucketAssets(ownerId, offset: offset, count: count),
bucketSource: () => _timelineRepository.watchRemoteBucket(
ownerId,
groupBy: GroupAssetsBy.month,
),
);
TimelineService favorite(String userId) => TimelineService( TimelineService favorite(String userId) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.favorite(userId, groupBy));
.getFavoriteBucketAssets(userId, offset: offset, count: count),
bucketSource: () =>
_timelineRepository.watchFavoriteBucket(userId, groupBy: groupBy),
);
TimelineService trash(String userId) => TimelineService( TimelineService trash(String userId) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.trash(userId, groupBy));
.getTrashBucketAssets(userId, offset: offset, count: count),
bucketSource: () =>
_timelineRepository.watchTrashBucket(userId, groupBy: groupBy),
);
TimelineService archive(String userId) => TimelineService( TimelineService archive(String userId) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.archived(userId, groupBy));
.getArchiveBucketAssets(userId, offset: offset, count: count),
bucketSource: () =>
_timelineRepository.watchArchiveBucket(userId, groupBy: groupBy),
);
TimelineService lockedFolder(String userId) => TimelineService( TimelineService lockedFolder(String userId) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.locked(userId, groupBy));
.getLockedFolderBucketAssets(userId, offset: offset, count: count),
bucketSource: () => _timelineRepository.watchLockedFolderBucket(
userId,
groupBy: groupBy,
),
);
TimelineService video(String userId) => TimelineService( TimelineService video(String userId) =>
assetSource: (offset, count) => _timelineRepository TimelineService(_timelineRepository.video(userId, groupBy));
.getVideoBucketAssets(userId, offset: offset, count: count),
bucketSource: () =>
_timelineRepository.watchVideoBucket(userId, groupBy: groupBy),
);
} }
class TimelineService { class TimelineService {
@@ -116,7 +75,13 @@ class TimelineService {
int _totalAssets = 0; int _totalAssets = 0;
int get totalAssets => _totalAssets; int get totalAssets => _totalAssets;
TimelineService({ TimelineService(TimelineQuery query)
: this._(
assetSource: query.assetSource,
bucketSource: query.bucketSource,
);
TimelineService._({
required TimelineAssetSource assetSource, required TimelineAssetSource assetSource,
required TimelineBucketSource bucketSource, required TimelineBucketSource bucketSource,
}) : _assetSource = assetSource, }) : _assetSource = assetSource,
@@ -210,6 +175,9 @@ class TimelineService {
Future<void> preCacheAssets(int index) => Future<void> preCacheAssets(int index) =>
_mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
BaseAsset getRandomAsset() =>
_buffer.elementAt(math.Random().nextInt(_buffer.length));
BaseAsset getAsset(int index) { BaseAsset getAsset(int index) {
if (!hasRange(index, 1)) { if (!hasRange(index, 1)) {
throw RangeError( throw RangeError(
+6 -6
View File
@@ -11,9 +11,9 @@ class FastScrollPhysics extends ScrollPhysics {
@override @override
SpringDescription get spring => const SpringDescription( SpringDescription get spring => const SpringDescription(
mass: 40, mass: 1,
stiffness: 100, stiffness: 402.49984375,
damping: 1, 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 // 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 // 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 // Making the animation faster is not just stylistic, but also helps to avoid this flicker
mass: 80, mass: 1,
stiffness: 100, stiffness: 1601.2499609375,
damping: 1, damping: 80,
); );
} }
@@ -1,15 +1,18 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.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.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
enum SortRemoteAlbumsBy { id } enum SortRemoteAlbumsBy { id, updatedAt }
class DriftRemoteAlbumRepository extends DriftDatabaseRepository { class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
const DriftRemoteAlbumRepository(this._db) : super(_db); const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<Album>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {}}) { Future<List<RemoteAlbum>> getAll({
Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt},
}) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
final query = _db.remoteAlbumEntity.select().join([ final query = _db.remoteAlbumEntity.select().join([
@@ -41,6 +44,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
orderings.add( orderings.add(
switch (sort) { switch (sort) {
SortRemoteAlbumsBy.id => OrderingTerm.asc(_db.remoteAlbumEntity.id), SortRemoteAlbumsBy.id => OrderingTerm.asc(_db.remoteAlbumEntity.id),
SortRemoteAlbumsBy.updatedAt =>
OrderingTerm.desc(_db.remoteAlbumEntity.updatedAt),
}, },
); );
} }
@@ -56,11 +61,54 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
) )
.get(); .get();
} }
Future<void> create(
RemoteAlbum album,
List<String> 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<int> removeAssets(String albumId, List<String> assetIds) {
return _db.remoteAlbumAssetEntity.deleteWhere(
(tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds),
);
}
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {
Album toDto({int assetCount = 0, required String ownerName}) { RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
return Album( return RemoteAlbum(
id: id, id: id,
name: name, name: name,
ownerId: ownerId, ownerId: ownerId,
@@ -5,8 +5,10 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.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/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_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:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:stream_transform/stream_transform.dart'; import 'package:stream_transform/stream_transform.dart';
@@ -30,19 +32,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.map((users) => users..add(userId)); .map((users) => users..add(userId));
} }
List<Bucket> _generateBuckets(int count) { TimelineQuery main(List<String> userIds, GroupAssetsBy groupBy) => (
final numBuckets = (count / kTimelineNoneSegmentSize).floor(); bucketSource: () => _watchMainBucket(
final buckets = List.generate( userIds,
numBuckets, groupBy: groupBy,
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize), ),
); assetSource: (offset, count) => _getMainBucketAssets(
if (count % kTimelineNoneSegmentSize != 0) { userIds,
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize)); offset: offset,
} count: count,
return buckets; ),
} );
Stream<List<Bucket>> watchMainBucket( Stream<List<Bucket>> _watchMainBucket(
List<String> userIds, { List<String> userIds, {
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
}) { }) {
@@ -62,7 +64,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.throttle(const Duration(seconds: 3), trailing: true); .throttle(const Duration(seconds: 3), trailing: true);
} }
Future<List<BaseAsset>> getMainBucketAssets( Future<List<BaseAsset>> _getMainBucketAssets(
List<String> userIds, { List<String> userIds, {
required int offset, required int offset,
required int count, required int count,
@@ -70,42 +72,53 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return _db.mergedAssetDrift return _db.mergedAssetDrift
.mergedAsset(userIds, limit: Limit(count, offset)) .mergedAsset(userIds, limit: Limit(count, offset))
.map( .map(
(row) { (row) => row.remoteId != null && row.ownerId != null
return row.remoteId != null && row.ownerId != null ? RemoteAsset(
? RemoteAsset( id: row.remoteId!,
id: row.remoteId!, localId: row.localId,
localId: row.localId, name: row.name,
name: row.name, ownerId: row.ownerId!,
ownerId: row.ownerId!, checksum: row.checksum,
checksum: row.checksum, type: row.type,
type: row.type, createdAt: row.createdAt,
createdAt: row.createdAt, updatedAt: row.updatedAt,
updatedAt: row.updatedAt, thumbHash: row.thumbHash,
thumbHash: row.thumbHash, width: row.width,
width: row.width, height: row.height,
height: row.height, isFavorite: row.isFavorite,
isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds,
durationInSeconds: row.durationInSeconds, )
) : LocalAsset(
: LocalAsset( id: row.localId!,
id: row.localId!, remoteId: row.remoteId,
remoteId: row.remoteId, name: row.name,
name: row.name, checksum: row.checksum,
checksum: row.checksum, type: row.type,
type: row.type, createdAt: row.createdAt,
createdAt: row.createdAt, updatedAt: row.updatedAt,
updatedAt: row.updatedAt, width: row.width,
width: row.width, height: row.height,
height: row.height, isFavorite: row.isFavorite,
isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds,
durationInSeconds: row.durationInSeconds, orientation: row.orientation,
orientation: row.orientation, ),
); )
}, .get();
).get();
} }
Stream<List<Bucket>> watchLocalAlbumBucket( TimelineQuery localAlbum(String albumId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchLocalAlbumBucket(
albumId,
groupBy: groupBy,
),
assetSource: (offset, count) => _getLocalAlbumBucketAssets(
albumId,
offset: offset,
count: count,
),
);
Stream<List<Bucket>> _watchLocalAlbumBucket(
String albumId, { String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
}) { }) {
@@ -119,15 +132,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final assetCountExp = _db.localAssetEntity.id.count(); final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy); 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]) ..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..groupBy([dateExp]) ..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]); ..orderBy([OrderingTerm.desc(dateExp)]);
@@ -139,7 +151,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch(); }).watch();
} }
Future<List<BaseAsset>> getLocalAlbumBucketAssets( Future<List<BaseAsset>> _getLocalAlbumBucketAssets(
String albumId, { String albumId, {
required int offset, required int offset,
required int count, required int count,
@@ -156,12 +168,25 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset); ..limit(count, offset: offset);
return query return query
.map((row) => row.readTable(_db.localAssetEntity).toDto()) .map((row) => row.readTable(_db.localAssetEntity).toDto())
.get(); .get();
} }
Stream<List<Bucket>> watchRemoteAlbumBucket( TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchRemoteAlbumBucket(
albumId,
groupBy: groupBy,
),
assetSource: (offset, count) => _getRemoteAlbumBucketAssets(
albumId,
offset: offset,
count: count,
),
);
Stream<List<Bucket>> _watchRemoteAlbumBucket(
String albumId, { String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
}) { }) {
@@ -199,7 +224,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch(); }).watch();
} }
Future<List<BaseAsset>> getRemoteAlbumBucketAssets( Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(
String albumId, { String albumId, {
required int offset, required int offset,
required int count, required int count,
@@ -225,325 +250,101 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.get(); .get();
} }
Stream<List<Bucket>> watchRemoteBucket( TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
String ownerId, { _remoteQueryBuilder(
GroupAssetsBy groupBy = GroupAssetsBy.day, filter: (row) =>
}) {
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<List<BaseAsset>> getRemoteBucketAssets(
String ownerId, {
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.deletedAt.isNull() & row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.timeline) & row.visibility.equalsValue(AssetVisibility.timeline) &
row.ownerId.equals(ownerId), row.ownerId.equals(ownerId),
) groupBy: groupBy,
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) );
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get(); TimelineQuery favorite(String userId, GroupAssetsBy groupBy) =>
} _remoteQueryBuilder(
filter: (row) =>
Stream<List<Bucket>> 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<List<BaseAsset>> getFavoriteBucketAssets(
String userId, {
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.deletedAt.isNull() & row.deletedAt.isNull() &
row.isFavorite.equals(true) & row.isFavorite.equals(true) &
row.ownerId.equals(userId), row.ownerId.equals(userId),
) groupBy: groupBy,
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) );
..limit(count, offset: offset);
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<List<Bucket>> watchTrashBucket( TimelineQuery archived(String userId, GroupAssetsBy groupBy) =>
String userId, { _remoteQueryBuilder(
GroupAssetsBy groupBy = GroupAssetsBy.day, filter: (row) =>
}) {
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<List<BaseAsset>> 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<List<Bucket>> 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<List<BaseAsset>> getArchiveBucketAssets(
String userId, {
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.deletedAt.isNull() & row.deletedAt.isNull() &
row.ownerId.equals(userId) & row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.archive), row.visibility.equalsValue(AssetVisibility.archive),
) groupBy: groupBy,
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) );
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get(); TimelineQuery locked(String userId, GroupAssetsBy groupBy) =>
} _remoteQueryBuilder(
filter: (row) =>
Stream<List<Bucket>> 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<List<BaseAsset>> getLockedFolderBucketAssets(
String userId, {
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.deletedAt.isNull() & row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.locked) & row.visibility.equalsValue(AssetVisibility.locked) &
row.ownerId.equals(userId), row.ownerId.equals(userId),
) groupBy: groupBy,
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) );
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get(); TimelineQuery video(String userId, GroupAssetsBy groupBy) =>
} _remoteQueryBuilder(
filter: (row) =>
Stream<List<Bucket>> 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<List<BaseAsset>> getVideoBucketAssets(
String userId, {
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.deletedAt.isNull() & row.deletedAt.isNull() &
row.type.equalsValue(AssetType.video) & row.type.equalsValue(AssetType.video) &
row.visibility.equalsValue(AssetVisibility.timeline) & row.visibility.equalsValue(AssetVisibility.timeline) &
row.ownerId.equals(userId), row.ownerId.equals(userId),
) groupBy: groupBy,
);
TimelineQuery _remoteQueryBuilder({
required Expression<bool> 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<List<Bucket>> _watchRemoteBucket({
required Expression<bool> 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<List<BaseAsset>> _getRemoteAssets({
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select()
..where(filter)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset); ..limit(count, offset: offset);
@@ -551,6 +352,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} }
} }
List<Bucket> _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<DateTime> { extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy) { Expression<String> dateFmt(GroupAssetsBy groupBy) {
// DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting // DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting
+8 -4
View File
@@ -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/app_navigation_observer.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/background.service.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/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.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/download.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
@@ -176,10 +176,13 @@ class ImmichAppState extends ConsumerState<ImmichApp>
final deepLinkHandler = ref.read(deepLinkServiceProvider); final deepLinkHandler = ref.read(deepLinkServiceProvider);
final currentRouteName = ref.read(currentRouteNameProvider.notifier).state; final currentRouteName = ref.read(currentRouteNameProvider.notifier).state;
final isColdStart =
currentRouteName == null || currentRouteName == SplashScreenRoute.name;
if (deepLink.uri.scheme == "immich") { if (deepLink.uri.scheme == "immich") {
final proposedRoute = await deepLinkHandler.handleScheme( final proposedRoute = await deepLinkHandler.handleScheme(
deepLink, deepLink,
currentRouteName == SplashScreenRoute.name, isColdStart,
); );
return proposedRoute; return proposedRoute;
@@ -188,7 +191,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
if (deepLink.uri.host == "my.immich.app") { if (deepLink.uri.host == "my.immich.app") {
final proposedRoute = await deepLinkHandler.handleMyImmichApp( final proposedRoute = await deepLinkHandler.handleMyImmichApp(
deepLink, deepLink,
currentRouteName == SplashScreenRoute.name, isColdStart,
); );
return proposedRoute; return proposedRoute;
@@ -250,7 +253,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
), ),
routerConfig: router.config( routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder, deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)], navigatorObservers: () =>
[AppNavigationObserver(ref: ref), HeroController()],
), ),
), ),
); );
@@ -4,13 +4,13 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.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/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/multiselect.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.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/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage() @RoutePage()
class TabControllerPage extends HookConsumerWidget { class TabControllerPage extends HookConsumerWidget {
@@ -158,10 +158,6 @@ class TabControllerPage extends HookConsumerWidget {
), ),
builder: (context, child) { builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context); final tabsRouter = AutoTabsRouter.of(context);
final heroedChild = HeroControllerScope(
controller: HeroController(),
child: child,
);
return PopScope( return PopScope(
canPop: tabsRouter.activeIndex == 0, canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) => onPopInvokedWithResult: (didPop, _) =>
@@ -173,10 +169,10 @@ class TabControllerPage extends HookConsumerWidget {
children: [ children: [
navigationRail(tabsRouter), navigationRail(tabsRouter),
const VerticalDivider(), const VerticalDivider(),
Expanded(child: heroedChild), Expanded(child: child),
], ],
) )
: heroedChild, : child,
bottomNavigationBar: multiselectEnabled || isScreenLandscape bottomNavigationBar: multiselectEnabled || isScreenLandscape
? null ? null
: bottomNavigationBar(tabsRouter), : bottomNavigationBar(tabsRouter),
+8 -6
View File
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.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/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -127,10 +128,6 @@ class TabShellPage extends ConsumerWidget {
), ),
builder: (context, child) { builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context); final tabsRouter = AutoTabsRouter.of(context);
final heroedChild = HeroControllerScope(
controller: HeroController(),
child: child,
);
return PopScope( return PopScope(
canPop: tabsRouter.activeIndex == 0, canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) => onPopInvokedWithResult: (didPop, _) =>
@@ -142,10 +139,10 @@ class TabShellPage extends ConsumerWidget {
children: [ children: [
navigationRail(tabsRouter), navigationRail(tabsRouter),
const VerticalDivider(), const VerticalDivider(),
Expanded(child: heroedChild), Expanded(child: child),
], ],
) )
: heroedChild, : child,
bottomNavigationBar: _BottomNavigationBar( bottomNavigationBar: _BottomNavigationBar(
tabsRouter: tabsRouter, tabsRouter: tabsRouter,
destinations: navigationDestinations, destinations: navigationDestinations,
@@ -168,6 +165,11 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
ref.read(searchInputFocusProvider).requestFocus(); ref.read(searchInputFocusProvider).requestFocus();
} }
// Album page
if (index == 2) {
ref.read(remoteAlbumProvider.notifier).getAll();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();
router.setActiveIndex(index); router.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index]; ref.read(tabProvider.notifier).state = TabEnum.values[index];
@@ -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(),
);
}
}
@@ -17,6 +17,21 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
final _features = [ 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( _Feature(
name: 'Selection Mode Timeline', name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded, icon: Icons.developer_mode_rounded,
@@ -42,23 +57,28 @@ final _features = [
return Future.value(); return Future.value();
}, },
), ),
_Feature(
name: '',
icon: Icons.vertical_align_center_sharp,
onTap: (_, __) => Future.value(),
),
_Feature( _Feature(
name: 'Sync Local', name: 'Sync Local',
icon: Icons.photo_album_rounded, icon: Icons.photo_album_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(), onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
), ),
_Feature( _Feature(
name: 'Sync Local Full', name: 'Sync Local Full (1)',
icon: Icons.photo_library_rounded, icon: Icons.photo_library_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true), onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
), ),
_Feature( _Feature(
name: 'Hash Local Assets', name: 'Hash Local Assets (2)',
icon: Icons.numbers_outlined, icon: Icons.numbers_outlined,
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(), onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
), ),
_Feature( _Feature(
name: 'Sync Remote', name: 'Sync Remote (3)',
icon: Icons.refresh_rounded, icon: Icons.refresh_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(), onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
), ),
@@ -69,6 +89,11 @@ final _features = [
.read(driftProvider) .read(driftProvider)
.customStatement("pragma wal_checkpoint(truncate)"), .customStatement("pragma wal_checkpoint(truncate)"),
), ),
_Feature(
name: '',
icon: Icons.vertical_align_center_sharp,
onTap: (_, __) => Future.value(),
),
_Feature( _Feature(
name: 'Clear Delta Checkpoint', name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded, 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() @RoutePage()
@@ -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(),
);
}
}
@@ -16,17 +16,18 @@ class MainTimelinePage extends ConsumerWidget {
return memoryLaneProvider.when( return memoryLaneProvider.when(
data: (memories) { data: (memories) {
return memories.isEmpty return memories.isEmpty
? const Timeline() ? const Timeline(showStorageIndicator: true)
: Timeline( : Timeline(
topSliverWidget: SliverToBoxAdapter( topSliverWidget: SliverToBoxAdapter(
key: Key('memory-lane-${memories.first.assets.first.id}'), key: Key('memory-lane-${memories.first.assets.first.id}'),
child: DriftMemoryLane(memories: memories), child: DriftMemoryLane(memories: memories),
), ),
topSliverWidgetHeight: 200, topSliverWidgetHeight: 200,
showStorageIndicator: true,
); );
}, },
loading: () => const Timeline(), loading: () => const Timeline(showStorageIndicator: true),
error: (error, stackTrace) => const Timeline(), error: (error, stackTrace) => const Timeline(showStorageIndicator: true),
); );
} }
} }
@@ -125,7 +125,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
name: album.name, name: album.name,
countFuture: countFuture, countFuture: countFuture,
onTap: () => context.router.push( onTap: () => context.router.push(
LocalTimelineRoute(albumId: album.id), LocalTimelineRoute(album: album),
), ),
); );
}, },
@@ -226,7 +226,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
name: album.name, name: album.name,
countFuture: countFuture, countFuture: countFuture,
onTap: () => context.router.push( onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id), RemoteAlbumRoute(album: album),
), ),
); );
}, },
@@ -97,7 +97,20 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
onRefresh: onRefresh, onRefresh: onRefresh,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
const ImmichSliverAppBar(), ImmichSliverAppBar(
actions: [
IconButton(
icon: const Icon(
Icons.add_rounded,
size: 28,
),
onPressed: () => context.pushRoute(
const DriftCreateAlbumRoute(),
),
),
],
showUploadButton: false,
),
_SearchBar( _SearchBar(
searchController: searchController, searchController: searchController,
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,
@@ -475,7 +488,7 @@ class _AlbumList extends StatelessWidget {
final bool isLoading; final bool isLoading;
final String? error; final String? error;
final List<Album> albums; final List<RemoteAlbum> albums;
final String? userId; final String? userId;
@override @override
@@ -555,7 +568,7 @@ class _AlbumList extends StatelessWidget {
), ),
), ),
onTap: () => context.router.push( onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id), RemoteAlbumRoute(album: album),
), ),
leadingPadding: const EdgeInsets.only( leadingPadding: const EdgeInsets.only(
right: 16, right: 16,
@@ -573,13 +586,24 @@ class _AlbumList extends StatelessWidget {
), ),
), ),
) )
: const SizedBox( : SizedBox(
width: 80, width: 80,
height: 80, height: 80,
child: Icon( child: Container(
Icons.photo_album_rounded, decoration: BoxDecoration(
size: 40, color: context.colorScheme.surfaceContainer,
color: Colors.grey, 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, required this.error,
}); });
final List<Album> albums; final List<RemoteAlbum> albums;
final String? userId; final String? userId;
final bool isLoading; final bool isLoading;
final String? error; final String? error;
@@ -674,14 +698,14 @@ class _GridAlbumCard extends StatelessWidget {
required this.userId, required this.userId,
}); });
final Album album; final RemoteAlbum album;
final String? userId; final String? userId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () => context.router.push( onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id), RemoteAlbumRoute(album: album),
), ),
child: Card( child: Card(
elevation: 0, elevation: 0,
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart'; 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: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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftArchivePage extends StatelessWidget { 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(),
),
); );
} }
} }
@@ -33,7 +33,7 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
if (user == null) { if (user == null) {
throw Exception( throw Exception(
'User must be logged in to access recently taken', 'User must be logged in to access asset selection timeline',
); );
} }
@@ -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<DriftCreateAlbumPage> createState() =>
_DriftCreateAlbumPageState();
}
class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
TextEditingController albumTitleController = TextEditingController();
TextEditingController albumDescriptionController = TextEditingController();
FocusNode albumTitleTextFieldFocusNode = FocusNode();
FocusNode albumDescriptionTextFieldFocusNode = FocusNode();
bool isAlbumTitleTextFieldFocus = false;
Set<BaseAsset> 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<void> onSelectPhotos() async {
final assets = await context.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(
lockedSelectionAssets: selectedAssets,
),
);
if (assets == null || assets.isEmpty) {
return;
}
setState(() {
selectedAssets = selectedAssets.union(assets);
});
}
Future<void> 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<bool> 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(),
),
),
);
}
}
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart'; 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: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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftFavoritePage extends StatelessWidget { 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(),
),
); );
} }
} }
@@ -27,7 +27,12 @@ class DriftLibraryPage extends ConsumerWidget {
return const Scaffold( return const Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
ImmichSliverAppBar(), ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
showUploadButton: false,
),
_ActionButtonGrid(), _ActionButtonGrid(),
_CollectionCards(), _CollectionCards(),
_QuickAccessButtonList(), _QuickAccessButtonList(),
@@ -507,8 +512,9 @@ class _PartnerList extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
).t(context: context, args: {'user': partner.name}), ).t(context: context, args: {'user': partner.name}),
onTap: () => onTap: () => context.pushRoute(
context.pushRoute(DriftPartnerDetailRoute(partnerId: partner.id)), DriftPartnerDetailRoute(partner: partner),
),
); );
}, },
); );
@@ -103,7 +103,7 @@ class _AlbumList extends ConsumerWidget {
), ),
), ),
onTap: () => onTap: () =>
context.pushRoute(LocalTimelineRoute(albumId: album.id)), context.pushRoute(LocalTimelineRoute(album: album)),
), ),
); );
}, },
@@ -1,9 +1,12 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftLockedFolderPage extends StatelessWidget { 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(),
),
); );
} }
} }
@@ -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(),
),
),
),
),
),
),
);
}
}
@@ -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,
),
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; 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: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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.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,
),
),
); );
} }
} }
@@ -1,14 +1,17 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class RemoteTimelinePage extends StatelessWidget { class LocalTimelinePage extends StatelessWidget {
final String albumId; final LocalAlbum album;
const RemoteTimelinePage({super.key, required this.albumId}); const LocalTimelinePage({super.key, required this.album});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -18,13 +21,16 @@ class RemoteTimelinePage extends StatelessWidget {
(ref) { (ref) {
final timelineService = ref final timelineService = ref
.watch(timelineFactoryProvider) .watch(timelineFactoryProvider)
.remoteAlbum(albumId: albumId); .localAlbum(albumId: album.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}, },
), ),
], ],
child: const Timeline(), child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name),
bottomSheet: const LocalAlbumBottomSheet(),
),
); );
} }
} }
@@ -1,16 +1,56 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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 { 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.remove_circle_outline, iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context), label: "remove_from_album".t(context: context),
onPressed: () => _onTap(context, ref),
); );
} }
} }
@@ -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/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnarchiveActionButton extends ConsumerWidget { class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
const UnarchiveActionButton({super.key, required this.source}); const UnArchiveActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -71,6 +71,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
StreamSubscription? reloadSubscription; StreamSubscription? reloadSubscription;
late Platform platform; late Platform platform;
late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown; bool? hasDraggedDown;
bool isSnapping = false; bool isSnapping = false;
@@ -98,6 +99,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onAssetChanged(widget.initialIndex); _onAssetChanged(widget.initialIndex);
}); });
reloadSubscription = EventStream.shared.listen(_onEvent); reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
} }
@override @override
@@ -335,7 +337,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final isDraggingDown = currentExtent < previousExtent; final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent; previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down // Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.5) { if (isDraggingDown && delta.extent < 0.55) {
if (dragInProgress) { if (dragInProgress) {
blockGestures = true; blockGestures = true;
} }
@@ -400,7 +402,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
previousExtent = _kBottomSheetMinimumExtent; previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet( sheetCloseController = showBottomSheet(
context: ctx, context: ctx,
sheetAnimationStyle: AnimationStyle( sheetAnimationStyle: const AnimationStyle(
duration: Durations.short4, duration: Durations.short4,
reverseDuration: Durations.short2, reverseDuration: Durations.short2,
), ),
@@ -487,7 +489,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag), key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size), imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag), heroAttributes:
PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999, initialScale: PhotoViewComputedScale.contained * 0.999,
@@ -521,7 +524,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onTapDown: _onTapDown, onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag), heroAttributes:
PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99, initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0, maxScale: 1.0,
@@ -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/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/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/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.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(),
],
],
);
}
}
@@ -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(),
],
],
);
}
}
@@ -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/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/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class HomeBottomAppBar extends ConsumerWidget { class GeneralBottomSheet extends ConsumerWidget {
const HomeBottomAppBar({super.key}); const GeneralBottomSheet({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -30,9 +30,10 @@ class HomeBottomAppBar extends ConsumerWidget {
return BaseBottomSheet( return BaseBottomSheet(
initialChildSize: 0.25, initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: [
if (multiselect.isEnabled) const ShareActionButton(), const ShareActionButton(),
if (multiselect.hasRemote) ...[ if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
@@ -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(),
],
);
}
}
@@ -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),
],
);
}
}
@@ -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(),
],
);
}
}
@@ -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,
),
],
);
}
}
@@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -25,6 +26,8 @@ class ThumbnailTile extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final assetContainerColor = context.isDarkTheme final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4) ? context.primaryColor.darken(amount: 0.4)
: context.primaryColor.lighten(amount: 0.75); : context.primaryColor.lighten(amount: 0.75);
@@ -64,7 +67,7 @@ class ThumbnailTile extends ConsumerWidget {
children: [ children: [
Positioned.fill( Positioned.fill(
child: Hero( child: Hero(
tag: asset.heroTag, tag: '${asset.heroTag}_$heroOffset',
child: Thumbnail( child: Thumbnail(
asset: asset, asset: asset,
fit: fit, fit: fit,
@@ -159,17 +159,18 @@ class _AssetTileWidget extends ConsumerWidget {
required this.assetIndex, required this.assetIndex,
}); });
void _handleOnTap( Future _handleOnTap(
BuildContext ctx, BuildContext ctx,
WidgetRef ref, WidgetRef ref,
int assetIndex, int assetIndex,
BaseAsset asset, BaseAsset asset,
) { ) async {
final multiSelectState = ref.read(multiSelectProvider); final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.forceEnable || multiSelectState.isEnabled) { if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
} else { } else {
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
ctx.pushRoute( ctx.pushRoute(
AssetViewerRoute( AssetViewerRoute(
initialIndex: assetIndex, initialIndex: assetIndex,
@@ -206,6 +207,9 @@ class _AssetTileWidget extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final lockSelection = _getLockSelectionStatus(ref); final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(
timelineArgsProvider.select((args) => args.showStorageIndicator),
);
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
@@ -217,6 +221,7 @@ class _AssetTileWidget extends ConsumerWidget {
child: ThumbnailTile( child: ThumbnailTile(
asset, asset,
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
), ),
), ),
); );
@@ -14,12 +14,14 @@ class TimelineArgs {
final double maxHeight; final double maxHeight;
final double spacing; final double spacing;
final int columnCount; final int columnCount;
final bool showStorageIndicator;
const TimelineArgs({ const TimelineArgs({
required this.maxWidth, required this.maxWidth,
required this.maxHeight, required this.maxHeight,
this.spacing = kTimelineSpacing, this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount, this.columnCount = kTimelineColumnCount,
this.showStorageIndicator = false,
}); });
@override @override
@@ -27,7 +29,8 @@ class TimelineArgs {
return spacing == other.spacing && return spacing == other.spacing &&
maxWidth == other.maxWidth && maxWidth == other.maxWidth &&
maxHeight == other.maxHeight && maxHeight == other.maxHeight &&
columnCount == other.columnCount; columnCount == other.columnCount &&
showStorageIndicator == other.showStorageIndicator;
} }
@override @override
@@ -35,7 +38,8 @@ class TimelineArgs {
maxWidth.hashCode ^ maxWidth.hashCode ^
maxHeight.hashCode ^ maxHeight.hashCode ^
spacing.hashCode ^ spacing.hashCode ^
columnCount.hashCode; columnCount.hashCode ^
showStorageIndicator.hashCode;
} }
class TimelineState { class TimelineState {
@@ -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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_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/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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/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'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class Timeline extends StatelessWidget { class Timeline extends StatelessWidget {
@@ -25,11 +26,16 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.showStorageIndicator = false,
this.appBar,
this.bottomSheet = const GeneralBottomSheet(),
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -43,12 +49,15 @@ class Timeline extends StatelessWidget {
columnCount: ref.watch( columnCount: ref.watch(
settingsProvider.select((s) => s.get(Setting.tilesPerRow)), settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
), ),
showStorageIndicator: showStorageIndicator,
), ),
), ),
], ],
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
appBar: appBar,
bottomSheet: bottomSheet,
), ),
), ),
), ),
@@ -60,10 +69,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.appBar,
this.bottomSheet,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? appBar;
final Widget? bottomSheet;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@@ -100,6 +113,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
onData: (segments) { onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final statusBarHeight = context.padding.top; final statusBarHeight = context.padding.top;
final double appBarExpandedHeight =
widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
? 200
: 0;
final totalAppBarHeight = statusBarHeight + kToolbarHeight; final totalAppBarHeight = statusBarHeight + kToolbarHeight;
const scrubberBottomPadding = 100.0; const scrubberBottomPadding = 100.0;
@@ -112,7 +129,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: totalAppBarHeight + 10, topPadding: totalAppBarHeight + 10,
bottomPadding: context.padding.bottom + scrubberBottomPadding, bottomPadding: context.padding.bottom + scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight, monthSegmentSnappingOffset:
widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView( child: CustomScrollView(
primary: true, primary: true,
cacheExtent: maxHeight * 2, cacheExtent: maxHeight * 2,
@@ -120,11 +138,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
if (isSelectionMode) if (isSelectionMode)
const SelectionSliverAppBar() const SelectionSliverAppBar()
else else
const ImmichSliverAppBar( widget.appBar ??
floating: true, const ImmichSliverAppBar(
pinned: false, floating: true,
snap: false, pinned: false,
), snap: false,
),
if (widget.topSliverWidget != null) widget.topSliverWidget!, if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList( _SliverSegmentedList(
segments: segments, segments: segments,
@@ -182,7 +201,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
child: const HomeBottomAppBar(), child: widget.bottomSheet,
), ),
], ],
], ],
@@ -228,6 +228,24 @@ class ActionNotifier extends Notifier<void> {
); );
} }
} }
Future<ActionResult> 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<RemoteAsset> { extension on Iterable<RemoteAsset> {
@@ -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/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.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<DriftLocalAlbumRepository>( final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@@ -30,7 +31,10 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
); );
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>( final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository)), (ref) => RemoteAlbumService(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
),
dependencies: [remoteAlbumRepository], dependencies: [remoteAlbumRepository],
); );
@@ -8,21 +8,21 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'album.provider.dart'; import 'album.provider.dart';
class RemoteAlbumState { class RemoteAlbumState {
final List<Album> albums; final List<RemoteAlbum> albums;
final List<Album> filteredAlbums; final List<RemoteAlbum> filteredAlbums;
final bool isLoading; final bool isLoading;
final String? error; final String? error;
const RemoteAlbumState({ const RemoteAlbumState({
required this.albums, required this.albums,
List<Album>? filteredAlbums, List<RemoteAlbum>? filteredAlbums,
this.isLoading = false, this.isLoading = false,
this.error, this.error,
}) : filteredAlbums = filteredAlbums ?? albums; }) : filteredAlbums = filteredAlbums ?? albums;
RemoteAlbumState copyWith({ RemoteAlbumState copyWith({
List<Album>? albums, List<RemoteAlbum>? albums,
List<Album>? filteredAlbums, List<RemoteAlbum>? filteredAlbums,
bool? isLoading, bool? isLoading,
String? error, String? error,
}) { }) {
@@ -66,7 +66,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return const RemoteAlbumState(albums: [], filteredAlbums: []); return const RemoteAlbumState(albums: [], filteredAlbums: []);
} }
Future<List<Album>> getAll() async { Future<List<RemoteAlbum>> getAll() async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
try { try {
@@ -118,4 +118,31 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse); .sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums); state = state.copyWith(filteredAlbums: sortedAlbums);
} }
Future<RemoteAlbum?> createAlbum({
required String title,
String? description,
List<String> 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;
}
}
} }
@@ -1,5 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' import 'package:immich_mobile/infrastructure/entities/user.entity.dart'
@@ -50,6 +52,25 @@ class AlbumApiRepository extends ApiRepository {
return _toAlbum(responseDto); return _toAlbum(responseDto);
} }
// TODO: Change name after removing old method
Future<RemoteAlbum> createDriftAlbum(
String name, {
required Iterable<String> assetIds,
String? description,
}) async {
final responseDto = await checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
description: description,
assetIds: assetIds.toList(),
),
),
);
return _toRemoteAlbum(responseDto);
}
Future<Album> update( Future<Album> update(
String albumId, { String albumId, {
String? name, String? name,
@@ -170,4 +191,22 @@ class AlbumApiRepository extends ApiRepository {
return album; 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,
);
}
} }
@@ -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<RemoteAlbum> createDriftAlbum(
String name, {
required Iterable<String> assetIds,
String? description,
}) async {
final responseDto = await checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
description: description,
assetIds: assetIds.toList(),
),
),
);
return responseDto.toRemoteAlbum();
}
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await checkNull(
_api.removeAssetFromAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> 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,
);
}
}
+19 -11
View File
@@ -1,6 +1,8 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/memory.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/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.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/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_partner_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/dev/drift_locked_folder.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/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/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.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_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.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_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/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
@@ -387,7 +390,7 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute( AutoRoute(
page: RemoteTimelineRoute.page, page: RemoteAlbumRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute( AutoRoute(
@@ -446,6 +449,11 @@ class AppRouter extends RootStackRouter {
page: DriftLocalAlbumsRoute.page, page: DriftLocalAlbumsRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute(
page: DriftCreateAlbumRoute.page,
guards: [_authGuard, _duplicateGuard],
),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),
+65 -49
View File
@@ -683,6 +683,22 @@ class DriftAssetSelectionTimelineRouteArgs {
} }
} }
/// generated route for
/// [DriftCreateAlbumPage]
class DriftCreateAlbumRoute extends PageRouteInfo<void> {
const DriftCreateAlbumRoute({List<PageRouteInfo>? children})
: super(DriftCreateAlbumRoute.name, initialChildren: children);
static const String name = 'DriftCreateAlbumRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftCreateAlbumPage();
},
);
}
/// generated route for /// generated route for
/// [DriftFavoritePage] /// [DriftFavoritePage]
class DriftFavoriteRoute extends PageRouteInfo<void> { class DriftFavoriteRoute extends PageRouteInfo<void> {
@@ -805,11 +821,11 @@ class DriftPartnerDetailRoute
extends PageRouteInfo<DriftPartnerDetailRouteArgs> { extends PageRouteInfo<DriftPartnerDetailRouteArgs> {
DriftPartnerDetailRoute({ DriftPartnerDetailRoute({
Key? key, Key? key,
required String partnerId, required UserDto partner,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
DriftPartnerDetailRoute.name, DriftPartnerDetailRoute.name,
args: DriftPartnerDetailRouteArgs(key: key, partnerId: partnerId), args: DriftPartnerDetailRouteArgs(key: key, partner: partner),
initialChildren: children, initialChildren: children,
); );
@@ -819,21 +835,21 @@ class DriftPartnerDetailRoute
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<DriftPartnerDetailRouteArgs>(); final args = data.argsAs<DriftPartnerDetailRouteArgs>();
return DriftPartnerDetailPage(key: args.key, partnerId: args.partnerId); return DriftPartnerDetailPage(key: args.key, partner: args.partner);
}, },
); );
} }
class DriftPartnerDetailRouteArgs { class DriftPartnerDetailRouteArgs {
const DriftPartnerDetailRouteArgs({this.key, required this.partnerId}); const DriftPartnerDetailRouteArgs({this.key, required this.partner});
final Key? key; final Key? key;
final String partnerId; final UserDto partner;
@override @override
String toString() { String toString() {
return 'DriftPartnerDetailRouteArgs{key: $key, partnerId: $partnerId}'; return 'DriftPartnerDetailRouteArgs{key: $key, partner: $partner}';
} }
} }
@@ -1211,11 +1227,11 @@ class LocalMediaSummaryRoute extends PageRouteInfo<void> {
class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> { class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
LocalTimelineRoute({ LocalTimelineRoute({
Key? key, Key? key,
required String albumId, required LocalAlbum album,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
LocalTimelineRoute.name, LocalTimelineRoute.name,
args: LocalTimelineRouteArgs(key: key, albumId: albumId), args: LocalTimelineRouteArgs(key: key, album: album),
initialChildren: children, initialChildren: children,
); );
@@ -1225,21 +1241,21 @@ class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<LocalTimelineRouteArgs>(); final args = data.argsAs<LocalTimelineRouteArgs>();
return LocalTimelinePage(key: args.key, albumId: args.albumId); return LocalTimelinePage(key: args.key, album: args.album);
}, },
); );
} }
class LocalTimelineRouteArgs { class LocalTimelineRouteArgs {
const LocalTimelineRouteArgs({this.key, required this.albumId}); const LocalTimelineRouteArgs({this.key, required this.album});
final Key? key; final Key? key;
final String albumId; final LocalAlbum album;
@override @override
String toString() { String toString() {
return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}'; return 'LocalTimelineRouteArgs{key: $key, album: $album}';
} }
} }
@@ -1744,6 +1760,43 @@ class RecentlyTakenRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [RemoteAlbumPage]
class RemoteAlbumRoute extends PageRouteInfo<RemoteAlbumRouteArgs> {
RemoteAlbumRoute({
Key? key,
required RemoteAlbum album,
List<PageRouteInfo>? 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<RemoteAlbumRouteArgs>();
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 /// generated route for
/// [RemoteMediaSummaryPage] /// [RemoteMediaSummaryPage]
class RemoteMediaSummaryRoute extends PageRouteInfo<void> { class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
@@ -1760,43 +1813,6 @@ class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [RemoteTimelinePage]
class RemoteTimelineRoute extends PageRouteInfo<RemoteTimelineRouteArgs> {
RemoteTimelineRoute({
Key? key,
required String albumId,
List<PageRouteInfo>? 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<RemoteTimelineRouteArgs>();
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 /// generated route for
/// [SearchPage] /// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> { class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
+21
View File
@@ -2,9 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/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/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.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/routing/router.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@@ -14,16 +17,22 @@ final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService( (ref) => ActionService(
ref.watch(assetApiRepositoryProvider), ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider), ref.watch(remoteAssetRepositoryProvider),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
), ),
); );
class ActionService { class ActionService {
final AssetApiRepository _assetApiRepository; final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository; final RemoteAssetRepository _remoteAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
const ActionService( const ActionService(
this._assetApiRepository, this._assetApiRepository,
this._remoteAssetRepository, this._remoteAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
); );
Future<void> shareLink(List<String> remoteIds, BuildContext context) async { Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -131,4 +140,16 @@ class ActionService {
return true; return true;
} }
Future<int> removeFromAlbum(List<String> 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;
}
} }
+28 -7
View File
@@ -1,38 +1,56 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse); typedef AlbumSortFn = List<RemoteAlbum> Function(
List<RemoteAlbum> albums,
bool isReverse,
);
class _RemoteAlbumSortHandlers { class _RemoteAlbumSortHandlers {
const _RemoteAlbumSortHandlers._(); const _RemoteAlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated; static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByCreated(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sortedBy((album) => album.createdAt); final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn title = _sortByTitle; static const AlbumSortFn title = _sortByTitle;
static List<Album> _sortByTitle(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByTitle(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sortedBy((album) => album.name); final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn lastModified = _sortByLastModified; static const AlbumSortFn lastModified = _sortByLastModified;
static List<Album> _sortByLastModified(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByLastModified(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sortedBy((album) => album.updatedAt); final sorted = albums.sortedBy((album) => album.updatedAt);
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn assetCount = _sortByAssetCount; static const AlbumSortFn assetCount = _sortByAssetCount;
static List<Album> _sortByAssetCount(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByAssetCount(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = final sorted =
albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount)); albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn mostRecent = _sortByMostRecent; static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByMostRecent(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sorted((a, b) { final sorted = albums.sorted((a, b) {
// For most recent, we sort by updatedAt in descending order // For most recent, we sort by updatedAt in descending order
return b.updatedAt.compareTo(a.updatedAt); return b.updatedAt.compareTo(a.updatedAt);
@@ -41,7 +59,10 @@ class _RemoteAlbumSortHandlers {
} }
static const AlbumSortFn mostOldest = _sortByMostOldest; static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByMostOldest(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sorted((a, b) { final sorted = albums.sorted((a, b) {
// For oldest, we sort by createdAt in ascending order // For oldest, we sort by createdAt in ascending order
return a.createdAt.compareTo(b.createdAt); return a.createdAt.compareTo(b.createdAt);
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -185,7 +186,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: action, child: action,
), ),
), ),
if (kDebugMode || kProfileMode) if (kDebugMode || kProfileMode || appFlavor == 'beta')
IconButton( IconButton(
icon: const Icon(Icons.science_rounded), icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()), onPressed: () => context.pushRoute(const FeatInDevRoute()),
@@ -73,7 +73,15 @@ class ImmichSliverAppBar extends ConsumerWidget {
onPressed: () => context.pop(), onPressed: () => context.pop(),
), ),
IconButton( 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( icon: const Icon(
Icons.sync, Icons.sync,
), ),
@@ -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<MesmerizingSliverAppBar> createState() =>
_MesmerizingSliverAppBarState();
}
class _MesmerizingSliverAppBarState
extends ConsumerState<MesmerizingSliverAppBar> {
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<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_slideAnimation = Tween<Offset>(
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<TimelineReloadEvent>((_) => 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<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _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<double>(
begin: 1.0,
end: 1.2,
).animate(
CurvedAnimation(
parent: _zoomController,
curve: Curves.easeInOut,
),
);
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(
CurvedAnimation(
parent: _zoomController,
curve: Curves.easeInOut,
),
);
_crossFadeAnimation = Tween<double>(
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<void> _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<void> _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],
),
);
},
),
),
),
],
),
),
);
},
);
}
}
+3
View File
@@ -29,3 +29,6 @@ translation:
dart run bin/generate_keys.dart dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart dart format lib/generated/codegen_loader.g.dart
dart format lib/generated/intl_keys.g.dart dart format lib/generated/intl_keys.g.dart
build-beta:
flutter build apk --flavor beta --release
+7
View File
@@ -208,6 +208,7 @@ Class | Method | HTTP request | Description
*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} |
*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions |
*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | *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* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets |
*SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | *SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links |
*SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | *SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links |
@@ -449,6 +450,7 @@ Class | Method | HTTP request | Description
- [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md)
- [SessionUnlockDto](doc//SessionUnlockDto.md) - [SessionUnlockDto](doc//SessionUnlockDto.md)
- [SessionUpdateDto](doc//SessionUpdateDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
@@ -481,11 +483,15 @@ Class | Method | HTTP request | Description
- [SyncMemoryV1](doc//SyncMemoryV1.md) - [SyncMemoryV1](doc//SyncMemoryV1.md)
- [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md)
- [SyncPartnerV1](doc//SyncPartnerV1.md) - [SyncPartnerV1](doc//SyncPartnerV1.md)
- [SyncPersonDeleteV1](doc//SyncPersonDeleteV1.md)
- [SyncPersonV1](doc//SyncPersonV1.md)
- [SyncRequestType](doc//SyncRequestType.md) - [SyncRequestType](doc//SyncRequestType.md)
- [SyncStackDeleteV1](doc//SyncStackDeleteV1.md) - [SyncStackDeleteV1](doc//SyncStackDeleteV1.md)
- [SyncStackV1](doc//SyncStackV1.md) - [SyncStackV1](doc//SyncStackV1.md)
- [SyncStreamDto](doc//SyncStreamDto.md) - [SyncStreamDto](doc//SyncStreamDto.md)
- [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md)
- [SyncUserMetadataDeleteV1](doc//SyncUserMetadataDeleteV1.md)
- [SyncUserMetadataV1](doc//SyncUserMetadataV1.md)
- [SyncUserV1](doc//SyncUserV1.md) - [SyncUserV1](doc//SyncUserV1.md)
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md) - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigDto](doc//SystemConfigDto.md)
@@ -503,6 +509,7 @@ Class | Method | HTTP request | Description
- [SystemConfigMapDto](doc//SystemConfigMapDto.md) - [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- [SystemConfigMetadataDto](doc//SystemConfigMetadataDto.md) - [SystemConfigMetadataDto](doc//SystemConfigMetadataDto.md)
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md) - [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
- [SystemConfigNightlyTasksDto](doc//SystemConfigNightlyTasksDto.md)
- [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md) - [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md) - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md) - [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
+6
View File
@@ -232,6 +232,7 @@ part 'model/session_create_dto.dart';
part 'model/session_create_response_dto.dart'; part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart'; part 'model/session_response_dto.dart';
part 'model/session_unlock_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_create_dto.dart';
part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_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_memory_v1.dart';
part 'model/sync_partner_delete_v1.dart'; part 'model/sync_partner_delete_v1.dart';
part 'model/sync_partner_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_request_type.dart';
part 'model/sync_stack_delete_v1.dart'; part 'model/sync_stack_delete_v1.dart';
part 'model/sync_stack_v1.dart'; part 'model/sync_stack_v1.dart';
part 'model/sync_stream_dto.dart'; part 'model/sync_stream_dto.dart';
part 'model/sync_user_delete_v1.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/sync_user_v1.dart';
part 'model/system_config_backups_dto.dart'; part 'model/system_config_backups_dto.dart';
part 'model/system_config_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_map_dto.dart';
part 'model/system_config_metadata_dto.dart'; part 'model/system_config_metadata_dto.dart';
part 'model/system_config_new_version_check_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_notifications_dto.dart';
part 'model/system_config_o_auth_dto.dart'; part 'model/system_config_o_auth_dto.dart';
part 'model/system_config_password_login_dto.dart'; part 'model/system_config_password_login_dto.dart';
+52
View File
@@ -219,4 +219,56 @@ class SessionsApi {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); 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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [SessionUpdateDto] sessionUpdateDto (required):
Future<SessionResponseDto?> 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;
}
} }
+12
View File
@@ -520,6 +520,8 @@ class ApiClient {
return SessionResponseDto.fromJson(value); return SessionResponseDto.fromJson(value);
case 'SessionUnlockDto': case 'SessionUnlockDto':
return SessionUnlockDto.fromJson(value); return SessionUnlockDto.fromJson(value);
case 'SessionUpdateDto':
return SessionUpdateDto.fromJson(value);
case 'SharedLinkCreateDto': case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value); return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto': case 'SharedLinkEditDto':
@@ -584,6 +586,10 @@ class ApiClient {
return SyncPartnerDeleteV1.fromJson(value); return SyncPartnerDeleteV1.fromJson(value);
case 'SyncPartnerV1': case 'SyncPartnerV1':
return SyncPartnerV1.fromJson(value); return SyncPartnerV1.fromJson(value);
case 'SyncPersonDeleteV1':
return SyncPersonDeleteV1.fromJson(value);
case 'SyncPersonV1':
return SyncPersonV1.fromJson(value);
case 'SyncRequestType': case 'SyncRequestType':
return SyncRequestTypeTypeTransformer().decode(value); return SyncRequestTypeTypeTransformer().decode(value);
case 'SyncStackDeleteV1': case 'SyncStackDeleteV1':
@@ -594,6 +600,10 @@ class ApiClient {
return SyncStreamDto.fromJson(value); return SyncStreamDto.fromJson(value);
case 'SyncUserDeleteV1': case 'SyncUserDeleteV1':
return SyncUserDeleteV1.fromJson(value); return SyncUserDeleteV1.fromJson(value);
case 'SyncUserMetadataDeleteV1':
return SyncUserMetadataDeleteV1.fromJson(value);
case 'SyncUserMetadataV1':
return SyncUserMetadataV1.fromJson(value);
case 'SyncUserV1': case 'SyncUserV1':
return SyncUserV1.fromJson(value); return SyncUserV1.fromJson(value);
case 'SystemConfigBackupsDto': case 'SystemConfigBackupsDto':
@@ -628,6 +638,8 @@ class ApiClient {
return SystemConfigMetadataDto.fromJson(value); return SystemConfigMetadataDto.fromJson(value);
case 'SystemConfigNewVersionCheckDto': case 'SystemConfigNewVersionCheckDto':
return SystemConfigNewVersionCheckDto.fromJson(value); return SystemConfigNewVersionCheckDto.fromJson(value);
case 'SystemConfigNightlyTasksDto':
return SystemConfigNightlyTasksDto.fromJson(value);
case 'SystemConfigNotificationsDto': case 'SystemConfigNotificationsDto':
return SystemConfigNotificationsDto.fromJson(value); return SystemConfigNotificationsDto.fromJson(value);
case 'SystemConfigOAuthDto': case 'SystemConfigOAuthDto':
+9 -1
View File
@@ -19,6 +19,7 @@ class SessionCreateResponseDto {
required this.deviceType, required this.deviceType,
this.expiresAt, this.expiresAt,
required this.id, required this.id,
required this.isPendingSyncReset,
required this.token, required this.token,
required this.updatedAt, required this.updatedAt,
}); });
@@ -41,6 +42,8 @@ class SessionCreateResponseDto {
String id; String id;
bool isPendingSyncReset;
String token; String token;
String updatedAt; String updatedAt;
@@ -53,6 +56,7 @@ class SessionCreateResponseDto {
other.deviceType == deviceType && other.deviceType == deviceType &&
other.expiresAt == expiresAt && other.expiresAt == expiresAt &&
other.id == id && other.id == id &&
other.isPendingSyncReset == isPendingSyncReset &&
other.token == token && other.token == token &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt;
@@ -65,11 +69,12 @@ class SessionCreateResponseDto {
(deviceType.hashCode) + (deviceType.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isPendingSyncReset.hashCode) +
(token.hashCode) + (token.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -83,6 +88,7 @@ class SessionCreateResponseDto {
// json[r'expiresAt'] = null; // json[r'expiresAt'] = null;
} }
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isPendingSyncReset'] = this.isPendingSyncReset;
json[r'token'] = this.token; json[r'token'] = this.token;
json[r'updatedAt'] = this.updatedAt; json[r'updatedAt'] = this.updatedAt;
return json; return json;
@@ -103,6 +109,7 @@ class SessionCreateResponseDto {
deviceType: mapValueOfType<String>(json, r'deviceType')!, deviceType: mapValueOfType<String>(json, r'deviceType')!,
expiresAt: mapValueOfType<String>(json, r'expiresAt'), expiresAt: mapValueOfType<String>(json, r'expiresAt'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isPendingSyncReset: mapValueOfType<bool>(json, r'isPendingSyncReset')!,
token: mapValueOfType<String>(json, r'token')!, token: mapValueOfType<String>(json, r'token')!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!, updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
); );
@@ -157,6 +164,7 @@ class SessionCreateResponseDto {
'deviceOS', 'deviceOS',
'deviceType', 'deviceType',
'id', 'id',
'isPendingSyncReset',
'token', 'token',
'updatedAt', 'updatedAt',
}; };
+9 -1
View File
@@ -19,6 +19,7 @@ class SessionResponseDto {
required this.deviceType, required this.deviceType,
this.expiresAt, this.expiresAt,
required this.id, required this.id,
required this.isPendingSyncReset,
required this.updatedAt, required this.updatedAt,
}); });
@@ -40,6 +41,8 @@ class SessionResponseDto {
String id; String id;
bool isPendingSyncReset;
String updatedAt; String updatedAt;
@override @override
@@ -50,6 +53,7 @@ class SessionResponseDto {
other.deviceType == deviceType && other.deviceType == deviceType &&
other.expiresAt == expiresAt && other.expiresAt == expiresAt &&
other.id == id && other.id == id &&
other.isPendingSyncReset == isPendingSyncReset &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt;
@override @override
@@ -61,10 +65,11 @@ class SessionResponseDto {
(deviceType.hashCode) + (deviceType.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isPendingSyncReset.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -78,6 +83,7 @@ class SessionResponseDto {
// json[r'expiresAt'] = null; // json[r'expiresAt'] = null;
} }
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isPendingSyncReset'] = this.isPendingSyncReset;
json[r'updatedAt'] = this.updatedAt; json[r'updatedAt'] = this.updatedAt;
return json; return json;
} }
@@ -97,6 +103,7 @@ class SessionResponseDto {
deviceType: mapValueOfType<String>(json, r'deviceType')!, deviceType: mapValueOfType<String>(json, r'deviceType')!,
expiresAt: mapValueOfType<String>(json, r'expiresAt'), expiresAt: mapValueOfType<String>(json, r'expiresAt'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isPendingSyncReset: mapValueOfType<bool>(json, r'isPendingSyncReset')!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!, updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
); );
} }
@@ -150,6 +157,7 @@ class SessionResponseDto {
'deviceOS', 'deviceOS',
'deviceType', 'deviceType',
'id', 'id',
'isPendingSyncReset',
'updatedAt', 'updatedAt',
}; };
} }
+108
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SessionUpdateDto(
isPendingSyncReset: mapValueOfType<bool>(json, r'isPendingSyncReset'),
);
}
return null;
}
static List<SessionUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SessionUpdateDto>[];
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<String, SessionUpdateDto> mapFromJson(dynamic json) {
final map = <String, SessionUpdateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SessionUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SessionUpdateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
};
}
+25 -1
View File
@@ -20,9 +20,11 @@ class SyncAssetV1 {
required this.fileModifiedAt, required this.fileModifiedAt,
required this.id, required this.id,
required this.isFavorite, required this.isFavorite,
required this.livePhotoVideoId,
required this.localDateTime, required this.localDateTime,
required this.originalFileName, required this.originalFileName,
required this.ownerId, required this.ownerId,
required this.stackId,
required this.thumbhash, required this.thumbhash,
required this.type, required this.type,
required this.visibility, required this.visibility,
@@ -42,12 +44,16 @@ class SyncAssetV1 {
bool isFavorite; bool isFavorite;
String? livePhotoVideoId;
DateTime? localDateTime; DateTime? localDateTime;
String originalFileName; String originalFileName;
String ownerId; String ownerId;
String? stackId;
String? thumbhash; String? thumbhash;
AssetTypeEnum type; AssetTypeEnum type;
@@ -63,9 +69,11 @@ class SyncAssetV1 {
other.fileModifiedAt == fileModifiedAt && other.fileModifiedAt == fileModifiedAt &&
other.id == id && other.id == id &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.livePhotoVideoId == livePhotoVideoId &&
other.localDateTime == localDateTime && other.localDateTime == localDateTime &&
other.originalFileName == originalFileName && other.originalFileName == originalFileName &&
other.ownerId == ownerId && other.ownerId == ownerId &&
other.stackId == stackId &&
other.thumbhash == thumbhash && other.thumbhash == thumbhash &&
other.type == type && other.type == type &&
other.visibility == visibility; other.visibility == visibility;
@@ -80,15 +88,17 @@ class SyncAssetV1 {
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(localDateTime == null ? 0 : localDateTime!.hashCode) + (localDateTime == null ? 0 : localDateTime!.hashCode) +
(originalFileName.hashCode) + (originalFileName.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) + (type.hashCode) +
(visibility.hashCode); (visibility.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -115,6 +125,11 @@ class SyncAssetV1 {
} }
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite; json[r'isFavorite'] = this.isFavorite;
if (this.livePhotoVideoId != null) {
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
} else {
// json[r'livePhotoVideoId'] = null;
}
if (this.localDateTime != null) { if (this.localDateTime != null) {
json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String(); json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String();
} else { } else {
@@ -122,6 +137,11 @@ class SyncAssetV1 {
} }
json[r'originalFileName'] = this.originalFileName; json[r'originalFileName'] = this.originalFileName;
json[r'ownerId'] = this.ownerId; json[r'ownerId'] = this.ownerId;
if (this.stackId != null) {
json[r'stackId'] = this.stackId;
} else {
// json[r'stackId'] = null;
}
if (this.thumbhash != null) { if (this.thumbhash != null) {
json[r'thumbhash'] = this.thumbhash; json[r'thumbhash'] = this.thumbhash;
} else { } else {
@@ -148,9 +168,11 @@ class SyncAssetV1 {
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!, isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
localDateTime: mapDateTime(json, r'localDateTime', r''), localDateTime: mapDateTime(json, r'localDateTime', r''),
originalFileName: mapValueOfType<String>(json, r'originalFileName')!, originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
stackId: mapValueOfType<String>(json, r'stackId'),
thumbhash: mapValueOfType<String>(json, r'thumbhash'), thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!,
@@ -208,9 +230,11 @@ class SyncAssetV1 {
'fileModifiedAt', 'fileModifiedAt',
'id', 'id',
'isFavorite', 'isFavorite',
'livePhotoVideoId',
'localDateTime', 'localDateTime',
'originalFileName', 'originalFileName',
'ownerId', 'ownerId',
'stackId',
'thumbhash', 'thumbhash',
'type', 'type',
'visibility', 'visibility',
+15
View File
@@ -56,7 +56,12 @@ class SyncEntityType {
static const memoryToAssetDeleteV1 = SyncEntityType._(r'MemoryToAssetDeleteV1'); static const memoryToAssetDeleteV1 = SyncEntityType._(r'MemoryToAssetDeleteV1');
static const stackV1 = SyncEntityType._(r'StackV1'); static const stackV1 = SyncEntityType._(r'StackV1');
static const stackDeleteV1 = SyncEntityType._(r'StackDeleteV1'); 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 syncAckV1 = SyncEntityType._(r'SyncAckV1');
static const syncResetV1 = SyncEntityType._(r'SyncResetV1');
/// List of all possible values in this [enum][SyncEntityType]. /// List of all possible values in this [enum][SyncEntityType].
static const values = <SyncEntityType>[ static const values = <SyncEntityType>[
@@ -93,7 +98,12 @@ class SyncEntityType {
memoryToAssetDeleteV1, memoryToAssetDeleteV1,
stackV1, stackV1,
stackDeleteV1, stackDeleteV1,
personV1,
personDeleteV1,
userMetadataV1,
userMetadataDeleteV1,
syncAckV1, syncAckV1,
syncResetV1,
]; ];
static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@@ -165,7 +175,12 @@ class SyncEntityTypeTypeTransformer {
case r'MemoryToAssetDeleteV1': return SyncEntityType.memoryToAssetDeleteV1; case r'MemoryToAssetDeleteV1': return SyncEntityType.memoryToAssetDeleteV1;
case r'StackV1': return SyncEntityType.stackV1; case r'StackV1': return SyncEntityType.stackV1;
case r'StackDeleteV1': return SyncEntityType.stackDeleteV1; 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'SyncAckV1': return SyncEntityType.syncAckV1;
case r'SyncResetV1': return SyncEntityType.syncResetV1;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
+99
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncPersonDeleteV1(
personId: mapValueOfType<String>(json, r'personId')!,
);
}
return null;
}
static List<SyncPersonDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncPersonDeleteV1>[];
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<String, SyncPersonDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncPersonDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncPersonDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncPersonDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'personId',
};
}
+191
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncPersonV1(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
createdAt: mapDateTime(json, r'createdAt', r'')!,
faceAssetId: mapValueOfType<String>(json, r'faceAssetId'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
}
return null;
}
static List<SyncPersonV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncPersonV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncPersonV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncPersonV1> mapFromJson(dynamic json) {
final map = <String, SyncPersonV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncPersonV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncPersonV1-objects as value to a dart map
static Map<String, List<SyncPersonV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncPersonV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncPersonV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'birthDate',
'color',
'createdAt',
'faceAssetId',
'id',
'isFavorite',
'isHidden',
'name',
'ownerId',
'thumbnailPath',
'updatedAt',
};
}
+6
View File
@@ -38,6 +38,8 @@ class SyncRequestType {
static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1'); static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1');
static const stacksV1 = SyncRequestType._(r'StacksV1'); static const stacksV1 = SyncRequestType._(r'StacksV1');
static const usersV1 = SyncRequestType._(r'UsersV1'); static const usersV1 = SyncRequestType._(r'UsersV1');
static const peopleV1 = SyncRequestType._(r'PeopleV1');
static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1');
/// List of all possible values in this [enum][SyncRequestType]. /// List of all possible values in this [enum][SyncRequestType].
static const values = <SyncRequestType>[ static const values = <SyncRequestType>[
@@ -56,6 +58,8 @@ class SyncRequestType {
partnerStacksV1, partnerStacksV1,
stacksV1, stacksV1,
usersV1, usersV1,
peopleV1,
userMetadataV1,
]; ];
static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value);
@@ -109,6 +113,8 @@ class SyncRequestTypeTypeTransformer {
case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1; case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1;
case r'StacksV1': return SyncRequestType.stacksV1; case r'StacksV1': return SyncRequestType.stacksV1;
case r'UsersV1': return SyncRequestType.usersV1; case r'UsersV1': return SyncRequestType.usersV1;
case r'PeopleV1': return SyncRequestType.peopleV1;
case r'UserMetadataV1': return SyncRequestType.userMetadataV1;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
+18 -1
View File
@@ -13,25 +13,41 @@ part of openapi.api;
class SyncStreamDto { class SyncStreamDto {
/// Returns a new [SyncStreamDto] instance. /// Returns a new [SyncStreamDto] instance.
SyncStreamDto({ SyncStreamDto({
this.reset,
this.types = const [], this.types = const [],
}); });
///
/// 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? reset;
List<SyncRequestType> types; List<SyncRequestType> types;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SyncStreamDto && bool operator ==(Object other) => identical(this, other) || other is SyncStreamDto &&
other.reset == reset &&
_deepEquality.equals(other.types, types); _deepEquality.equals(other.types, types);
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(reset == null ? 0 : reset!.hashCode) +
(types.hashCode); (types.hashCode);
@override @override
String toString() => 'SyncStreamDto[types=$types]'; String toString() => 'SyncStreamDto[reset=$reset, types=$types]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.reset != null) {
json[r'reset'] = this.reset;
} else {
// json[r'reset'] = null;
}
json[r'types'] = this.types; json[r'types'] = this.types;
return json; return json;
} }
@@ -45,6 +61,7 @@ class SyncStreamDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return SyncStreamDto( return SyncStreamDto(
reset: mapValueOfType<bool>(json, r'reset'),
types: SyncRequestType.listFromJson(json[r'types']), types: SyncRequestType.listFromJson(json[r'types']),
); );
} }
+107
View File
@@ -0,0 +1,107 @@
//
// 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 SyncUserMetadataDeleteV1 {
/// Returns a new [SyncUserMetadataDeleteV1] instance.
SyncUserMetadataDeleteV1({
required this.key,
required this.userId,
});
String key;
String userId;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncUserMetadataDeleteV1 &&
other.key == key &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(key.hashCode) +
(userId.hashCode);
@override
String toString() => 'SyncUserMetadataDeleteV1[key=$key, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'key'] = this.key;
json[r'userId'] = this.userId;
return json;
}
/// Returns a new [SyncUserMetadataDeleteV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncUserMetadataDeleteV1? fromJson(dynamic value) {
upgradeDto(value, "SyncUserMetadataDeleteV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncUserMetadataDeleteV1(
key: mapValueOfType<String>(json, r'key')!,
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<SyncUserMetadataDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncUserMetadataDeleteV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncUserMetadataDeleteV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncUserMetadataDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncUserMetadataDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncUserMetadataDeleteV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncUserMetadataDeleteV1-objects as value to a dart map
static Map<String, List<SyncUserMetadataDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncUserMetadataDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncUserMetadataDeleteV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'key',
'userId',
};
}

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