forked from Cutlery/immich
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b560249eed | |||
| 14978ece65 | |||
| 289194a356 | |||
| 3f93fda4e4 | |||
| 108e10f175 | |||
| 7023143bce | |||
| e4a8e77c67 | |||
| 8e5ce7f684 | |||
| f86f073d50 | |||
| d7580a3413 | |||
| 324890e182 | |||
| 7cccd216c2 | |||
| c98a8e1bb1 | |||
| 989a406721 | |||
| 7f20d689ea | |||
| f168f9e117 | |||
| 8bd90669c0 | |||
| 8f7e06bebd | |||
| eac150114f | |||
| 296ae54335 |
Generated
+1919
-158
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@testcontainers/postgresql": "^10.7.1",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/p
|
||||
import path from 'node:path';
|
||||
import yaml from 'yaml';
|
||||
import { ImmichApi } from './api.service';
|
||||
|
||||
class LoginError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -15,11 +14,13 @@ class LoginError extends Error {
|
||||
}
|
||||
|
||||
export class SessionService {
|
||||
private get authPath() {
|
||||
return path.join(this.configDirectory, '/auth.yml');
|
||||
}
|
||||
readonly configDirectory!: string;
|
||||
readonly authPath!: string;
|
||||
|
||||
constructor(private configDirectory: string) {}
|
||||
constructor(configDirectory: string) {
|
||||
this.configDirectory = configDirectory;
|
||||
this.authPath = path.join(configDirectory, '/auth.yml');
|
||||
}
|
||||
|
||||
async connect(): Promise<ImmichApi> {
|
||||
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||
@@ -47,8 +48,6 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
|
||||
|
||||
const api = new ImmichApi(instanceUrl, apiKey);
|
||||
|
||||
const pingResponse = await api.pingServer().catch((error) => {
|
||||
@@ -63,9 +62,7 @@ export class SessionService {
|
||||
}
|
||||
|
||||
async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
|
||||
console.log(`Logging in to ${instanceUrl}`);
|
||||
|
||||
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
|
||||
console.log('Logging in...');
|
||||
|
||||
const api = new ImmichApi(instanceUrl, apiKey);
|
||||
|
||||
@@ -86,7 +83,7 @@ export class SessionService {
|
||||
|
||||
await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 });
|
||||
|
||||
console.log(`Wrote auth info to ${this.authPath}`);
|
||||
console.log('Wrote auth info to ' + this.authPath);
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -101,18 +98,4 @@ export class SessionService {
|
||||
|
||||
console.log('Successfully logged out');
|
||||
}
|
||||
|
||||
private async resolveApiEndpoint(instanceUrl: string): Promise<string> {
|
||||
const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
|
||||
try {
|
||||
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
|
||||
const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
|
||||
if (endpoint !== instanceUrl) {
|
||||
console.debug(`Discovered API at ${endpoint}`);
|
||||
}
|
||||
return endpoint;
|
||||
} catch {
|
||||
return instanceUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,18 +124,16 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
|
||||
## Machine Learning
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :----------------------------------------------- | :------------------------------------------------------------------- | :-----------------: | :--------------- |
|
||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning |
|
||||
| Variable | Description | Default | Services |
|
||||
| :----------------------------------------------- | :----------------------------------------------------------------- | :-----------------: | :--------------- |
|
||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` | machine learning |
|
||||
|
||||
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
||||
|
||||
|
||||
@@ -28,10 +28,6 @@ Or before beginning app installation, [create the datasets](https://www.truenas.
|
||||
Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
|
||||
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
|
||||
|
||||
:::info Permissions
|
||||
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
||||
:::
|
||||
|
||||
## Installing the Immich Application
|
||||
|
||||
To install the **Immich** application, go to **Apps**, click **Discover Apps**, either begin typing Immich into the search field or scroll down to locate the **Immich** application widget.
|
||||
|
||||
Generated
+4
-4
@@ -12231,7 +12231,7 @@
|
||||
"mime-format": "2.0.0",
|
||||
"mime-types": "2.1.27",
|
||||
"postman-url-encoder": "2.1.3",
|
||||
"sanitize-html": "^2.12.1",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"semver": "^7.5.4",
|
||||
"uuid": "3.4.0"
|
||||
}
|
||||
@@ -14762,9 +14762,9 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz",
|
||||
"integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
|
||||
"integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
|
||||
Generated
+14
-14
@@ -924,12 +924,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0.tgz",
|
||||
"integrity": "sha512-2k1HzC28Fs+HiwbJOQDUwrWMttqSLUVdjCqitBOjdCD0svWOMQUVqrXX6iFD7POps6xXAojsX/dGBpKnjZctLA==",
|
||||
"version": "1.41.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
|
||||
"integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.42.0"
|
||||
"playwright": "1.41.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1156,9 +1156,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.11.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
|
||||
"integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
|
||||
"version": "20.11.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
|
||||
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
@@ -3870,12 +3870,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.42.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0.tgz",
|
||||
"integrity": "sha512-Ko7YRUgj5xBHbntrgt4EIw/nE//XBHOKVKnBjO1KuZkmkhlbgyggTe5s9hjqQ1LpN+Xg+kHsQyt5Pa0Bw5XpvQ==",
|
||||
"version": "1.41.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz",
|
||||
"integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.42.0"
|
||||
"playwright-core": "1.41.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3888,9 +3888,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.42.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0.tgz",
|
||||
"integrity": "sha512-0HD9y8qEVlcbsAjdpBaFjmaTHf+1FeIddy8VJLeiqwhcNqGCBe4Wp2e8knpqiYbzxtxarxiXyNDw2cG8sCaNMQ==",
|
||||
"version": "1.41.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz",
|
||||
"integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
} from '@immich/sdk';
|
||||
import { exiftool } from 'exiftool-vendored';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils, fileUtils, tempDir, testAssetDir, wsUtils } from 'src/utils';
|
||||
import { apiUtils, app, dbUtils, tempDir, testAssetDir, wsUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -20,6 +21,8 @@ const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||
|
||||
const sha1 = (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64');
|
||||
|
||||
const readTags = async (bytes: Buffer, filename: string) => {
|
||||
const filepath = join(tempDir, filename);
|
||||
await writeFile(filepath, bytes);
|
||||
@@ -736,8 +739,8 @@ describe('/asset', () => {
|
||||
const asset = await apiUtils.getAssetInfo(admin.accessToken, assetLocation.id);
|
||||
|
||||
const original = await readFile(locationAssetFilepath);
|
||||
const originalChecksum = fileUtils.sha1(original);
|
||||
const downloadChecksum = fileUtils.sha1(body);
|
||||
const originalChecksum = sha1(original);
|
||||
const downloadChecksum = sha1(body);
|
||||
|
||||
expect(originalChecksum).toBe(downloadChecksum);
|
||||
expect(downloadChecksum).toBe(asset.checksum);
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils, fileUtils, tempDir } from 'src/utils';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/download', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
let asset2: AssetFileUploadResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup();
|
||||
[asset1, asset2] = await Promise.all([
|
||||
apiUtils.createAsset(admin.accessToken),
|
||||
apiUtils.createAsset(admin.accessToken),
|
||||
]);
|
||||
asset1 = await apiUtils.createAsset(admin.accessToken);
|
||||
});
|
||||
|
||||
describe('POST /download/info', () => {
|
||||
@@ -45,39 +40,6 @@ describe('/download', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/archive', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/download/archive`)
|
||||
.send({ assetIds: [asset1.id, asset2.id] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should download an archive', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/download/archive')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ assetIds: [asset1.id, asset2.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body instanceof Buffer).toBe(true);
|
||||
|
||||
await writeFile(`${tempDir}/archive.zip`, body);
|
||||
await fileUtils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`);
|
||||
const files = [
|
||||
{ filename: 'example.png', id: asset1.id },
|
||||
{ filename: 'example+1.png', id: asset2.id },
|
||||
];
|
||||
for (const { id, filename } of files) {
|
||||
const bytes = await readFile(`${tempDir}/archive/${filename}`);
|
||||
const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
|
||||
expect(fileUtils.sha1(bytes)).toBe(asset.checksum);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/download/asset/${asset1.id}`);
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('/activity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let visiblePerson: PersonResponseDto;
|
||||
let hiddenPerson: PersonResponseDto;
|
||||
let multipleAssetsPerson: PersonResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
@@ -20,7 +19,7 @@ describe('/activity', () => {
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset(['person']);
|
||||
|
||||
[visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([
|
||||
[visiblePerson, hiddenPerson] = await Promise.all([
|
||||
apiUtils.createPerson(admin.accessToken, {
|
||||
name: 'visible_person',
|
||||
}),
|
||||
@@ -28,20 +27,13 @@ describe('/activity', () => {
|
||||
name: 'hidden_person',
|
||||
isHidden: true,
|
||||
}),
|
||||
apiUtils.createPerson(admin.accessToken, {
|
||||
name: 'multiple_assets_person',
|
||||
}),
|
||||
]);
|
||||
|
||||
const asset1 = await apiUtils.createAsset(admin.accessToken);
|
||||
const asset2 = await apiUtils.createAsset(admin.accessToken);
|
||||
const asset = await apiUtils.createAsset(admin.accessToken);
|
||||
|
||||
await Promise.all([
|
||||
dbUtils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
|
||||
dbUtils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
|
||||
dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
|
||||
dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
|
||||
dbUtils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
|
||||
dbUtils.createFace({ assetId: asset.id, personId: visiblePerson.id }),
|
||||
dbUtils.createFace({ assetId: asset.id, personId: hiddenPerson.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -63,10 +55,9 @@ describe('/activity', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
total: 3,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
expect.objectContaining({ name: 'multiple_assets_person' }),
|
||||
expect.objectContaining({ name: 'visible_person' }),
|
||||
expect.objectContaining({ name: 'hidden_person' }),
|
||||
],
|
||||
@@ -78,12 +69,9 @@ describe('/activity', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
total: 3,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
expect.objectContaining({ name: 'multiple_assets_person' }),
|
||||
expect.objectContaining({ name: 'visible_person' }),
|
||||
],
|
||||
people: [expect.objectContaining({ name: 'visible_person' })],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -115,33 +103,6 @@ describe('/activity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /person/:id/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/person/${multipleAssetsPerson.id}/statistics`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should throw error if person with id does not exist', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/person/${uuidDto.notFound}/statistics`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should return the correct number of assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/person/${multipleAssetsPerson.id}/statistics`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(expect.objectContaining({ assets: 2 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /person/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`);
|
||||
|
||||
@@ -29,12 +29,12 @@ describe(`immich login-key`, () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should login and save auth.yml with 600', async () => {
|
||||
it('should login', async () => {
|
||||
const admin = await apiUtils.adminSetup();
|
||||
const key = await apiUtils.createApiKey(admin.accessToken);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
'Logging in to http://127.0.0.1:2283/api',
|
||||
'Logging in...',
|
||||
'Logged in as admin@immich.cloud',
|
||||
'Wrote auth info to /tmp/immich/auth.yml',
|
||||
]);
|
||||
@@ -45,18 +45,4 @@ describe(`immich login-key`, () => {
|
||||
const mode = (stats.mode & 0o777).toString(8);
|
||||
expect(mode).toEqual('600');
|
||||
});
|
||||
|
||||
it('should login without /api in the url', async () => {
|
||||
const admin = await apiUtils.adminSetup();
|
||||
const key = await apiUtils.createApiKey(admin.accessToken);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login-key', app.replaceAll('/api', ''), `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
'Logging in to http://127.0.0.1:2283',
|
||||
'Discovered API at http://127.0.0.1:2283/api',
|
||||
'Logged in as admin@immich.cloud',
|
||||
'Wrote auth info to /tmp/immich/auth.yml',
|
||||
]);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-6
@@ -25,7 +25,6 @@ import {
|
||||
} from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -77,10 +76,6 @@ export const fileUtils = {
|
||||
reset: async () => {
|
||||
await execPromise(`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
|
||||
},
|
||||
unzip: async (input: string, output: string) => {
|
||||
await execPromise(`unzip -o -d "${output}" "${input}"`);
|
||||
},
|
||||
sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'),
|
||||
};
|
||||
|
||||
export const dbUtils = {
|
||||
@@ -155,7 +150,7 @@ export interface CliResponse {
|
||||
export const immichCli = async (args: string[]) => {
|
||||
let _resolve: (value: CliResponse) => void;
|
||||
const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
|
||||
const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args];
|
||||
const _args = ['node_modules/.bin/immich', '-d', '/tmp/immich/', ...args];
|
||||
const child = spawn('node', _args, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
@@ -167,8 +167,6 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
*.onnx
|
||||
*.zip
|
||||
@@ -39,7 +39,7 @@ FROM python:3.11-slim-bookworm@sha256:ce81dc539f0aedc9114cae640f8352fad83d37461c
|
||||
FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as prod-openvino
|
||||
USER root
|
||||
|
||||
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:8b51b1fe922964d73c482a267b5b519e990d90bf744ec7a40419923737caff6d as prod-cuda
|
||||
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04@sha256:85fb7ac694079fff1061a0140fd5b5a641997880e12112d92589c3bbb1e8b7ca as prod-cuda
|
||||
|
||||
COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from socket import socket
|
||||
|
||||
from gunicorn.arbiter import Arbiter
|
||||
from pydantic import BaseModel, BaseSettings
|
||||
from pydantic import BaseSettings
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from uvicorn import Server
|
||||
@@ -15,11 +15,6 @@ from uvicorn.workers import UvicornWorker
|
||||
from .schemas import ModelType
|
||||
|
||||
|
||||
class PreloadModelData(BaseModel):
|
||||
clip: str | None
|
||||
facial_recognition: str | None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
cache_folder: str = "/cache"
|
||||
model_ttl: int = 300
|
||||
@@ -32,12 +27,10 @@ class Settings(BaseSettings):
|
||||
model_inter_op_threads: int = 0
|
||||
model_intra_op_threads: int = 0
|
||||
ann: bool = True
|
||||
preload: PreloadModelData | None = None
|
||||
|
||||
class Config:
|
||||
env_prefix = "MACHINE_LEARNING_"
|
||||
case_sensitive = False
|
||||
env_nested_delimiter = "__"
|
||||
|
||||
|
||||
class LogSettings(BaseSettings):
|
||||
|
||||
@@ -17,7 +17,7 @@ from starlette.formparsers import MultiPartParser
|
||||
|
||||
from app.models.base import InferenceModel
|
||||
|
||||
from .config import PreloadModelData, log, settings
|
||||
from .config import log, settings
|
||||
from .models.cache import ModelCache
|
||||
from .schemas import (
|
||||
MessageResponse,
|
||||
@@ -27,7 +27,7 @@ from .schemas import (
|
||||
|
||||
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger
|
||||
|
||||
model_cache = ModelCache(revalidate=settings.model_ttl > 0)
|
||||
model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
|
||||
thread_pool: ThreadPoolExecutor | None = None
|
||||
lock = threading.Lock()
|
||||
active_requests = 0
|
||||
@@ -51,8 +51,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
log.info(f"Initialized request thread pool with {settings.request_threads} threads.")
|
||||
if settings.model_ttl > 0 and settings.model_ttl_poll_s > 0:
|
||||
asyncio.ensure_future(idle_shutdown_task())
|
||||
if settings.preload is not None:
|
||||
await preload_models(settings.preload)
|
||||
yield
|
||||
finally:
|
||||
log.handlers.clear()
|
||||
@@ -63,14 +61,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
gc.collect()
|
||||
|
||||
|
||||
async def preload_models(preload_models: PreloadModelData) -> None:
|
||||
log.info(f"Preloading models: {preload_models}")
|
||||
if preload_models.clip is not None:
|
||||
await load(await model_cache.get(preload_models.clip, ModelType.CLIP))
|
||||
if preload_models.facial_recognition is not None:
|
||||
await load(await model_cache.get(preload_models.facial_recognition, ModelType.FACIAL_RECOGNITION))
|
||||
|
||||
|
||||
def update_state() -> Iterator[None]:
|
||||
global active_requests, last_called
|
||||
active_requests += 1
|
||||
@@ -113,7 +103,7 @@ async def predict(
|
||||
except orjson.JSONDecodeError:
|
||||
raise HTTPException(400, f"Invalid options JSON: {options}")
|
||||
|
||||
model = await load(await model_cache.get(model_name, model_type, ttl=settings.model_ttl, **kwargs))
|
||||
model = await load(await model_cache.get(model_name, model_type, **kwargs))
|
||||
model.configure(**kwargs)
|
||||
outputs = await run(model.predict, inputs)
|
||||
return ORJSONResponse(outputs)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Any
|
||||
|
||||
from aiocache.backends.memory import SimpleMemoryCache
|
||||
from aiocache.lock import OptimisticLock
|
||||
from aiocache.plugins import TimingPlugin
|
||||
from aiocache.plugins import BasePlugin, TimingPlugin
|
||||
|
||||
from app.models import from_model_type
|
||||
|
||||
@@ -15,25 +15,28 @@ class ModelCache:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ttl: float | None = None,
|
||||
revalidate: bool = False,
|
||||
timeout: int | None = None,
|
||||
profiling: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
ttl: Unloads model after this duration. Disabled if None. Defaults to None.
|
||||
revalidate: Resets TTL on cache hit. Useful to keep models in memory while active. Defaults to False.
|
||||
timeout: Maximum allowed time for model to load. Disabled if None. Defaults to None.
|
||||
profiling: Collects metrics for cache operations, adding slight overhead. Defaults to False.
|
||||
"""
|
||||
|
||||
self.ttl = ttl
|
||||
plugins = []
|
||||
|
||||
if revalidate:
|
||||
plugins.append(RevalidationPlugin())
|
||||
if profiling:
|
||||
plugins.append(TimingPlugin())
|
||||
|
||||
self.revalidate_enable = revalidate
|
||||
|
||||
self.cache = SimpleMemoryCache(timeout=timeout, plugins=plugins, namespace=None)
|
||||
self.cache = SimpleMemoryCache(ttl=ttl, timeout=timeout, plugins=plugins, namespace=None)
|
||||
|
||||
async def get(self, model_name: str, model_type: ModelType, **model_kwargs: Any) -> InferenceModel:
|
||||
"""
|
||||
@@ -46,14 +49,11 @@ class ModelCache:
|
||||
"""
|
||||
|
||||
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
|
||||
|
||||
async with OptimisticLock(self.cache, key) as lock:
|
||||
model: InferenceModel | None = await self.cache.get(key)
|
||||
if model is None:
|
||||
model = from_model_type(model_type, model_name, **model_kwargs)
|
||||
await lock.cas(model, ttl=model_kwargs.get("ttl", None))
|
||||
elif self.revalidate_enable:
|
||||
await self.revalidate(key, model_kwargs.get("ttl", None))
|
||||
await lock.cas(model, ttl=self.ttl)
|
||||
return model
|
||||
|
||||
async def get_profiling(self) -> dict[str, float] | None:
|
||||
@@ -62,6 +62,21 @@ class ModelCache:
|
||||
|
||||
return self.cache.profiling
|
||||
|
||||
async def revalidate(self, key: str, ttl: int | None) -> None:
|
||||
if ttl is not None and key in self.cache._handlers:
|
||||
await self.cache.expire(key, ttl)
|
||||
|
||||
class RevalidationPlugin(BasePlugin): # type: ignore[misc]
|
||||
"""Revalidates cache item's TTL after cache hit."""
|
||||
|
||||
async def post_get(
|
||||
self,
|
||||
client: SimpleMemoryCache,
|
||||
key: str,
|
||||
ret: Any | None = None,
|
||||
namespace: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if ret is None:
|
||||
return
|
||||
if namespace is not None:
|
||||
key = client.build_key(key, namespace)
|
||||
if key in client._handlers:
|
||||
await client.expire(key, client.ttl)
|
||||
|
||||
@@ -13,12 +13,11 @@ import onnxruntime as ort
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from app.main import load, preload_models
|
||||
from app.main import load
|
||||
|
||||
from .config import Settings, log, settings
|
||||
from .config import log, settings
|
||||
from .models.base import InferenceModel
|
||||
from .models.cache import ModelCache
|
||||
from .models.clip import MCLIPEncoder, OpenCLIPEncoder
|
||||
@@ -510,20 +509,20 @@ class TestCache:
|
||||
|
||||
@mock.patch("app.models.cache.OptimisticLock", autospec=True)
|
||||
async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
|
||||
model_cache = ModelCache(ttl=100)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
|
||||
|
||||
@mock.patch("app.models.cache.SimpleMemoryCache.expire")
|
||||
async def test_revalidate_get(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache(revalidate=True)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
|
||||
model_cache = ModelCache(ttl=100, revalidate=True)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
mock_cache_expire.assert_called_once_with(mock.ANY, 100)
|
||||
|
||||
async def test_profiling(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache(profiling=True)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
|
||||
model_cache = ModelCache(ttl=100, profiling=True)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
profiling = await model_cache.get_profiling()
|
||||
assert isinstance(profiling, dict)
|
||||
assert profiling == model_cache.cache.profiling
|
||||
@@ -549,25 +548,6 @@ class TestCache:
|
||||
with pytest.raises(ValueError):
|
||||
await model_cache.get("test_model_name", ModelType.CLIP, mode="text")
|
||||
|
||||
async def test_preloads_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
||||
os.environ["MACHINE_LEARNING_PRELOAD__CLIP"] = "ViT-B-32__openai"
|
||||
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"] = "buffalo_s"
|
||||
|
||||
settings = Settings()
|
||||
assert settings.preload is not None
|
||||
assert settings.preload.clip == "ViT-B-32__openai"
|
||||
assert settings.preload.facial_recognition == "buffalo_s"
|
||||
|
||||
model_cache = ModelCache()
|
||||
monkeypatch.setattr("app.main.model_cache", model_cache)
|
||||
|
||||
await preload_models(settings.preload)
|
||||
assert len(model_cache.cache._cache) == 2
|
||||
assert mock_get_model.call_count == 2
|
||||
await model_cache.get("ViT-B-32__openai", ModelType.CLIP, ttl=100)
|
||||
await model_cache.get("buffalo_s", ModelType.FACIAL_RECOGNITION, ttl=100)
|
||||
assert mock_get_model.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLoad:
|
||||
|
||||
Generated
+14
-18
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiocache"
|
||||
@@ -2030,21 +2030,21 @@ sympy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-gpu"
|
||||
version = "1.17.1"
|
||||
version = "1.17.0"
|
||||
description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "onnxruntime_gpu-1.17.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e34ecb2b527ee1265135ae74cd99ea198ff344b8221929a920596a1e461e2bbb"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:37786c0f225be90da0a66ca413fe125a925a0900263301cc4dbcad4ff0404673"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3bde190a683ec84ecf61bd390f3c275d388efe72404633df374c52c557ce6d4d"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:5206c84caa770efcc2ca819f71ec007a244ed748ca04e7ff76b86df1a096d2c8"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0396ec73de565a64509d96dff154f531f8da8023c191f771ceba47a3f4efc266"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:8531d4a833c8e978c5ff1de7b3bcc4126bbe58ea71fae54ddce58fe8777cb136"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7b831f9eafd626f3d44955420a4b1b84f9ffcb987712a0ab6a37d1ee9f2f7a45"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:a389334d3797519d4b12077db32b8764f1ce54374d0f89235edc04efe8bc192c"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:27aeaa36385e459b3867577ed7f68c1756de79aa68f57141d4ae2a31c84f6a33"},
|
||||
{file = "onnxruntime_gpu-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b46094ea348aff6c6494402ac4260e2d2aba0522ae13e1ae29d98a29384ed70"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1f2a4e0468ac0bd8246996c3d5dbba92cbbaca874bcd7f9cee4e99ce6eb27f5b"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:0721b7930d7abed3730b2335e639e60d94ec411bb4d35a0347cc9c8b52c34540"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:be0314afe399943904de7c1ca797cbcc63e6fad60eb85d3df6422f81dd94e79e"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:52125c24b21406d1431e43de1c98cea29c21e0cceba80db530b7e4c9216d86ea"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bb802d8033885c412269f8bc8877d8779b0dc874df6fb9df8b796cba7276ad66"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:8c43533e3e5335eaa78059fb86b849a4faded513a00c1feaaa205ca5af51c40f"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:1d461455bba160836d6c11c648c8fd4e4500d5c17096a13e6c2c9d22a4abd436"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4398f2175a92f4b35d95279a6294a89c462f24de058a2736ee1d498bab5a16"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1d0e3805cd1c024aba7f4ae576fd08545fc27530a2aaad2b3c8ac0ee889fbd05"},
|
||||
{file = "onnxruntime_gpu-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc1da5b93363ee600b5b220b04eeec51ad2c2b3e96f0b7615b16b8a173c88001"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2055,11 +2055,6 @@ packaging = "*"
|
||||
protobuf = "*"
|
||||
sympy = "*"
|
||||
|
||||
[package.source]
|
||||
type = "legacy"
|
||||
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple"
|
||||
reference = "cuda12"
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-openvino"
|
||||
version = "1.15.0"
|
||||
@@ -2633,6 +2628,7 @@ files = [
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||
@@ -3623,4 +3619,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.10,<3.12"
|
||||
content-hash = "c947090d326e81179054b7ce4dded311df8b7ca5a56680d5e9459cf8ca18df1a"
|
||||
content-hash = "c982d5c5fee76ca102d823010a538f287ac98583f330ebee3c0775c5f42f117d"
|
||||
|
||||
@@ -45,7 +45,7 @@ onnxruntime = "^1.15.0"
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.cuda.dependencies]
|
||||
onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"}
|
||||
onnxruntime-gpu = "^1.15.0"
|
||||
|
||||
[tool.poetry.group.openvino]
|
||||
optional = true
|
||||
@@ -59,11 +59,6 @@ optional = true
|
||||
[tool.poetry.group.armnn.dependencies]
|
||||
onnxruntime = "^1.15.0"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "cuda12"
|
||||
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
|
||||
priority = "explicit"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
@@ -62,12 +62,9 @@ fi
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
npm --prefix server version "$SERVER_PUMP"
|
||||
make open-api
|
||||
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
|
||||
npm --prefix web version "$SERVER_PUMP"
|
||||
npm --prefix web i --package-lock-only
|
||||
npm --prefix cli i --package-lock-only
|
||||
npm --prefix e2e i --package-lock-only
|
||||
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
|
||||
make open-api
|
||||
poetry --directory machine-learning version "$SERVER_PUMP"
|
||||
fi
|
||||
|
||||
|
||||
@@ -180,4 +180,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
COCOAPODS: 1.11.3
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
extension LocalAlbumIsarHelper on IsarCollection<LocalAlbum> {
|
||||
Future<void> store(LocalAlbum a) async {
|
||||
await put(a);
|
||||
await a.thumb.save();
|
||||
await a.assets.save();
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteAlbumIsarHelper on IsarCollection<RemoteAlbum> {
|
||||
Future<void> store(RemoteAlbum a) async {
|
||||
await put(a);
|
||||
await a.owner.save();
|
||||
await a.thumb.save();
|
||||
await a.sharedUsers.save();
|
||||
await a.assets.save();
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumIsarHelper on IsarCollection<BackupAlbum> {
|
||||
Future<void> store(BackupAlbum a) async {
|
||||
await put(a);
|
||||
await a.album.save();
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumResponseDtoHelper on AlbumResponseDto {
|
||||
List<Asset> getAssets() => assets.map(Asset.remote).toList();
|
||||
}
|
||||
|
||||
extension AssetPathEntityHelper on AssetPathEntity {
|
||||
String get eTagKeyAssetCount => "device-album-$id-asset-count";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
extension OptionalCast on Object {
|
||||
T? tryCast<T>() => this is T ? this as T : null;
|
||||
}
|
||||
|
||||
extension NullUtilities on Object? {
|
||||
/// Returns true if object is null or is empty
|
||||
bool get isNullOrEmpty => (this == null || _isIterableAndEmpty);
|
||||
|
||||
bool get _isIterableAndEmpty =>
|
||||
this is Iterable ? (this as Iterable).isEmpty : false;
|
||||
}
|
||||
+11
-13
@@ -9,20 +9,18 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/device_asset.dart';
|
||||
import 'package:timezone/data/latest.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/shared/cache/widgets_binding.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/android_device_asset.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/etag.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/models/ios_device_asset.dart';
|
||||
import 'package:immich_mobile/shared/models/logger_message.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
@@ -73,14 +71,15 @@ Future<void> initApp() async {
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
log.severe(
|
||||
'FlutterError - Catch all',
|
||||
"${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
|
||||
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
|
||||
details,
|
||||
details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
log.severe('PlatformDispatcher - Catch all', error, stack);
|
||||
log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
|
||||
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -94,14 +93,13 @@ Future<Isar> loadDb() async {
|
||||
StoreValueSchema,
|
||||
ExifInfoSchema,
|
||||
AssetSchema,
|
||||
AlbumSchema,
|
||||
UserSchema,
|
||||
BackupAlbumSchema,
|
||||
DuplicatedAssetSchema,
|
||||
LocalAlbumSchema,
|
||||
RemoteAlbumSchema,
|
||||
UserSchema,
|
||||
LoggerMessageSchema,
|
||||
ETagSchema,
|
||||
if (Platform.isAndroid) AndroidDeviceAssetSchema,
|
||||
if (Platform.isIOS) IOSDeviceAssetSchema,
|
||||
DeviceAssetSchema,
|
||||
],
|
||||
directory: dir.path,
|
||||
maxSizeMiB: 256,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/activities/providers/activity.provider.dar
|
||||
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
@@ -22,15 +23,15 @@ class ActivitiesPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Album has to be set in the provider before reaching this page
|
||||
final album = ref.watch(currentAlbumProvider)!;
|
||||
// Album has to be set in the provider before reaching this page and has to be a RemoteAlbum
|
||||
final album = ref.watch(currentAlbumProvider)! as RemoteAlbum;
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activityNotifier = ref
|
||||
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
|
||||
final activityNotifier =
|
||||
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
|
||||
final activities =
|
||||
ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
|
||||
ref.watch(albumActivityProvider(album.id, asset?.remoteId));
|
||||
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ class ActivityTextField extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
final activityNotifier = ref
|
||||
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
|
||||
final activityNotifier =
|
||||
ref.read(albumActivityProvider(album.id, asset?.remoteId).notifier);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final inputController = useTextEditingController();
|
||||
final inputFocusNode = useFocusNode();
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// ignore_for_file: add-copy-with
|
||||
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
part 'album.model.g.dart';
|
||||
|
||||
/// Acts as a common class for RemoteAlbums and LocalAlbums to perform generic album handling irrespective of
|
||||
/// where the album is from
|
||||
sealed class Album {
|
||||
Id get isarId => fastHash(id);
|
||||
@Index(unique: true, replace: true, type: IndexType.hash)
|
||||
final String id;
|
||||
String name;
|
||||
DateTime modifiedAt;
|
||||
final IsarLink<Asset> thumb = IsarLink<Asset>();
|
||||
|
||||
static const assetsLinkId = 'assets';
|
||||
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
||||
|
||||
@ignore
|
||||
int get assetCount => assets.length;
|
||||
|
||||
@ignore
|
||||
Asset? get thumbnail => thumb.value;
|
||||
|
||||
Album({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.modifiedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Album(id: $id, name: $name, assetCount: $assetCount)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Album other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.name == name &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.thumb == thumb &&
|
||||
other.assetCount == assetCount;
|
||||
}
|
||||
|
||||
@override
|
||||
@ignore
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
name.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
thumb.hashCode ^
|
||||
assetCount.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@Collection()
|
||||
class LocalAlbum extends Album {
|
||||
static const isAllId = 'isAll';
|
||||
|
||||
@Backlink(to: BackupAlbum.albumLinkId)
|
||||
final IsarLink<BackupAlbum> backup = IsarLink<BackupAlbum>();
|
||||
|
||||
LocalAlbum({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.modifiedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LocalAlbum(id: $id, name: $name, assetCount: $assetCount)';
|
||||
}
|
||||
|
||||
static LocalAlbum fromAssetPathEntity(
|
||||
AssetPathEntity ape, {
|
||||
Asset? thumbnail,
|
||||
Iterable<Asset>? assets,
|
||||
}) {
|
||||
final album = LocalAlbum(
|
||||
id: ape.id,
|
||||
name: ape.name,
|
||||
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||
);
|
||||
|
||||
if (assets != null) {
|
||||
album.assets.addAll(assets);
|
||||
}
|
||||
|
||||
album.thumb.value = thumbnail;
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
@Collection()
|
||||
class RemoteAlbum extends Album {
|
||||
DateTime createdAt;
|
||||
DateTime? startDate;
|
||||
DateTime? endDate;
|
||||
DateTime? lastModifiedAssetTimestamp;
|
||||
bool shared;
|
||||
bool activityEnabled;
|
||||
final IsarLink<User> owner = IsarLink<User>();
|
||||
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||
|
||||
@ignore
|
||||
String? get ownerId => owner.value?.id;
|
||||
|
||||
@ignore
|
||||
String? get ownerName {
|
||||
// Guard null owner
|
||||
if (owner.value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final name = <String>[];
|
||||
if (owner.value?.name != null) {
|
||||
name.add(owner.value!.name);
|
||||
}
|
||||
|
||||
return name.join(' ');
|
||||
}
|
||||
|
||||
RemoteAlbum({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.modifiedAt,
|
||||
required this.createdAt,
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
this.shared = false,
|
||||
this.activityEnabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RemoteAlbum(id: $id, name: $name, assetCount: $assetCount, createdAt: $createdAt, startDate: $startDate, endDate: $endDate, lastModifiedAssetTimestamp: $lastModifiedAssetTimestamp, shared: $shared, activityEnabled: $activityEnabled)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant RemoteAlbum other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
final lastModifiedAssetTimestampIsSetAndEqual =
|
||||
lastModifiedAssetTimestamp != null &&
|
||||
other.lastModifiedAssetTimestamp != null
|
||||
? lastModifiedAssetTimestamp!
|
||||
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
|
||||
: true;
|
||||
|
||||
return super == other &&
|
||||
other.createdAt == createdAt &&
|
||||
other.startDate == startDate &&
|
||||
other.endDate == endDate &&
|
||||
lastModifiedAssetTimestampIsSetAndEqual &&
|
||||
other.shared == shared &&
|
||||
other.activityEnabled == activityEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return super.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
startDate.hashCode ^
|
||||
endDate.hashCode ^
|
||||
lastModifiedAssetTimestamp.hashCode ^
|
||||
shared.hashCode ^
|
||||
activityEnabled.hashCode;
|
||||
}
|
||||
|
||||
static Future<RemoteAlbum> fromDto(AlbumResponseDto dto, Isar db) async {
|
||||
final album = RemoteAlbum(
|
||||
id: dto.id,
|
||||
name: dto.albumName,
|
||||
createdAt: dto.createdAt,
|
||||
modifiedAt: dto.updatedAt,
|
||||
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
|
||||
shared: dto.shared,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
activityEnabled: dto.isActivityEnabled,
|
||||
);
|
||||
|
||||
album.owner.value = await db.users.getById(dto.ownerId);
|
||||
|
||||
if (dto.albumThumbnailAssetId != null) {
|
||||
album.thumb.value = await db.assets
|
||||
.where()
|
||||
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
if (dto.sharedUsers.isNotEmpty) {
|
||||
final users = await db.users
|
||||
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
|
||||
album.sharedUsers.addAll(users.cast());
|
||||
}
|
||||
|
||||
if (dto.assets.isNotEmpty) {
|
||||
final assets =
|
||||
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
|
||||
album.assets.addAll(assets);
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
}
|
||||
+2605
File diff suppressed because it is too large
Load Diff
@@ -1,74 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||
AlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
|
||||
query.findAll().then((value) {
|
||||
if (mounted) {
|
||||
state = value;
|
||||
}
|
||||
});
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
}
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<void> getAllAlbums() => Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
|
||||
Future<void> getDeviceAlbums() => _albumService.refreshDeviceAlbums();
|
||||
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) =>
|
||||
_albumService.createAlbum(albumTitle, assets, []);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final albumProvider =
|
||||
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
|
||||
return AlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final albumWatcher =
|
||||
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
|
||||
final remoteAlbumWatcher =
|
||||
StreamProvider.autoDispose.family<RemoteAlbum, int>((ref, albumId) async* {
|
||||
final db = ref.watch(dbProvider);
|
||||
final a = await db.albums.get(albumId);
|
||||
final a = await db.remoteAlbums.get(albumId);
|
||||
if (a != null) yield a;
|
||||
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
|
||||
await for (final a
|
||||
in db.remoteAlbums.watchObject(albumId, fireImmediately: true)) {
|
||||
if (a != null) yield a;
|
||||
}
|
||||
});
|
||||
|
||||
final albumRenderlistProvider =
|
||||
final remoteAlbumRenderlistProvider =
|
||||
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
|
||||
final album = ref.watch(albumWatcher(albumId)).value;
|
||||
final album = ref.watch(remoteAlbumWatcher(albumId)).value;
|
||||
if (album != null) {
|
||||
final query =
|
||||
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
|
||||
@@ -76,3 +28,25 @@ final albumRenderlistProvider =
|
||||
}
|
||||
return const Stream.empty();
|
||||
});
|
||||
|
||||
final localAlbumWatcher =
|
||||
StreamProvider.autoDispose.family<LocalAlbum, int>((ref, albumId) async* {
|
||||
final db = ref.watch(dbProvider);
|
||||
final a = await db.localAlbums.get(albumId);
|
||||
if (a != null) yield a;
|
||||
await for (final a
|
||||
in db.localAlbums.watchObject(albumId, fireImmediately: true)) {
|
||||
if (a != null) yield a;
|
||||
}
|
||||
});
|
||||
|
||||
final localAlbumRenderlistProvider =
|
||||
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
|
||||
final album = ref.watch(localAlbumWatcher(albumId)).value;
|
||||
if (album != null) {
|
||||
final query =
|
||||
album.assets.filter().localIdIsNotNull().sortByFileCreatedAtDesc();
|
||||
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
|
||||
}
|
||||
return const Stream.empty();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'album_sort_by_options.provider.g.dart';
|
||||
@@ -11,9 +11,13 @@ typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
|
||||
class _AlbumSortHandlers {
|
||||
const _AlbumSortHandlers._();
|
||||
|
||||
/// Sorts a List<Album> based on their created date.
|
||||
///
|
||||
/// ! This is not support for LocalAlbums and they are filtered out from the result
|
||||
static const AlbumSortFn created = _sortByCreated;
|
||||
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sortedBy((album) => album.createdAt);
|
||||
final sorted =
|
||||
albums.whereType<RemoteAlbum>().sortedBy((album) => album.createdAt);
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
@@ -36,9 +40,12 @@ class _AlbumSortHandlers {
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
/// Sorts a List<Album> based on the most recent assets.
|
||||
///
|
||||
/// ! This is not support for LocalAlbums and they are filtered out from the result
|
||||
static const AlbumSortFn mostRecent = _sortByMostRecent;
|
||||
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
|
||||
if (a.endDate != null && b.endDate != null) {
|
||||
return a.endDate!.compareTo(b.endDate!);
|
||||
}
|
||||
@@ -49,9 +56,12 @@ class _AlbumSortHandlers {
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
/// Sorts a List<Album> based on the most oldest assets.
|
||||
///
|
||||
/// ! This is not support for LocalAlbums and they are filtered out from the result
|
||||
static const AlbumSortFn mostOldest = _sortByMostOldest;
|
||||
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final sorted = albums.whereType<RemoteAlbum>().sorted((a, b) {
|
||||
if (a.startDate != null && b.startDate != null) {
|
||||
return a.startDate!.compareTo(b.startDate!);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
|
||||
AlbumViewerNotifier(this.ref)
|
||||
@@ -31,7 +31,7 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
|
||||
}
|
||||
|
||||
Future<bool> changeAlbumTitle(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
AlbumService service = ref.watch(albumServiceProvider);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'current_album.provider.g.dart';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album_service.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'local_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class LocalAlbums extends _$LocalAlbums {
|
||||
@override
|
||||
Stream<List<LocalAlbum>> build() async* {
|
||||
final db = ref.read(dbProvider);
|
||||
final stream =
|
||||
db.localAlbums.where().watch().listen((v) => state = AsyncData(v));
|
||||
ref.onDispose(() => stream.cancel());
|
||||
yield await db.localAlbums.where().findAll();
|
||||
}
|
||||
|
||||
Future<void> getDeviceAlbums() async {
|
||||
final hasChanges =
|
||||
await ref.read(localAlbumServiceProvider).refreshDeviceAlbums();
|
||||
if (hasChanges) {
|
||||
ref.read(backupAlbumsProvider.notifier).refreshAlbumAssetsState();
|
||||
ref.invalidate(deviceAssetsProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'local_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$localAlbumsHash() => r'15274162ef40aa87f498a5cf6f8f8a5e0d4c69a8';
|
||||
|
||||
/// See also [LocalAlbums].
|
||||
@ProviderFor(LocalAlbums)
|
||||
final localAlbumsProvider =
|
||||
AutoDisposeStreamNotifierProvider<LocalAlbums, List<LocalAlbum>>.internal(
|
||||
LocalAlbums.new,
|
||||
name: r'localAlbumsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$localAlbumsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$LocalAlbums = AutoDisposeStreamNotifier<List<LocalAlbum>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup_album.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/hash.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'local_album_service.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
LocalAlbumService localAlbumService(LocalAlbumServiceRef ref) =>
|
||||
LocalAlbumService(
|
||||
ref.watch(dbProvider),
|
||||
ref.read(hashServiceProvider),
|
||||
ref.read(syncServiceProvider),
|
||||
ref.read(backupAlbumServiceProvider),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'local_album_service.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$localAlbumServiceHash() => r'362ac6700cf340ace2d6de4c691e1e900d3a28c8';
|
||||
|
||||
/// See also [localAlbumService].
|
||||
@ProviderFor(localAlbumService)
|
||||
final localAlbumServiceProvider = Provider<LocalAlbumService>.internal(
|
||||
localAlbumService,
|
||||
name: r'localAlbumServiceProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$localAlbumServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef LocalAlbumServiceRef = ProviderRef<LocalAlbumService>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'remote_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class RemoteAlbums extends _$RemoteAlbums {
|
||||
@override
|
||||
Stream<List<RemoteAlbum>> build() async* {
|
||||
final db = ref.read(dbProvider);
|
||||
final stream =
|
||||
db.remoteAlbums.where().watch().listen((v) => state = AsyncData(v));
|
||||
ref.onDispose(() => stream.cancel());
|
||||
yield await db.remoteAlbums.where().findAll();
|
||||
}
|
||||
|
||||
Future<void> getRemoteAlbums([bool isShared = false]) =>
|
||||
ref.read(albumServiceProvider).refreshRemoteAlbums(isShared: isShared);
|
||||
|
||||
Future<bool> deleteAlbum(RemoteAlbum album) =>
|
||||
ref.read(albumServiceProvider).deleteAlbum(album);
|
||||
|
||||
Future<RemoteAlbum?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) =>
|
||||
ref.read(albumServiceProvider).createAlbum(albumTitle, assets, []);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'remote_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$remoteAlbumsHash() => r'bae71f612bbfcd3e2f193dd63eef53ea1d822af2';
|
||||
|
||||
/// See also [RemoteAlbums].
|
||||
@ProviderFor(RemoteAlbums)
|
||||
final remoteAlbumsProvider =
|
||||
AutoDisposeStreamNotifierProvider<RemoteAlbums, List<RemoteAlbum>>.internal(
|
||||
RemoteAlbums.new,
|
||||
name: r'remoteAlbumsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$remoteAlbumsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$RemoteAlbums = AutoDisposeStreamNotifier<List<RemoteAlbum>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -2,16 +2,17 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
class SharedAlbumNotifier extends StateNotifier<List<RemoteAlbum>> {
|
||||
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
final query =
|
||||
db.remoteAlbums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
query.findAll().then((value) {
|
||||
if (mounted) {
|
||||
state = value;
|
||||
@@ -21,9 +22,9 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
}
|
||||
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
late final StreamSubscription<List<RemoteAlbum>> _streamSub;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
Future<RemoteAlbum?> createSharedAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets,
|
||||
Iterable<User> sharedUsers,
|
||||
@@ -43,9 +44,10 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Future<void> getAllSharedAlbums() =>
|
||||
_albumService.refreshRemoteAlbums(isShared: true);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
Future<bool> deleteAlbum(RemoteAlbum album) =>
|
||||
_albumService.deleteAlbum(album);
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
Future<bool> leaveAlbum(RemoteAlbum album) async {
|
||||
var res = await _albumService.leaveAlbum(album);
|
||||
|
||||
if (res) {
|
||||
@@ -56,11 +58,11 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||
Future<bool> removeAssetFromAlbum(RemoteAlbum album, Iterable<Asset> assets) {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(Album album, User user) async {
|
||||
Future<bool> removeUserFromAlbum(RemoteAlbum album, User user) async {
|
||||
final result = await _albumService.removeUserFromAlbum(album, user);
|
||||
|
||||
if (result && album.sharedUsers.isEmpty) {
|
||||
@@ -70,7 +72,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
|
||||
Future<bool> setActivityEnabled(RemoteAlbum album, bool activityEnabled) {
|
||||
return _albumService.setActivityEnabled(album, activityEnabled);
|
||||
}
|
||||
|
||||
@@ -82,7 +84,8 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
}
|
||||
|
||||
final sharedAlbumProvider =
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<RemoteAlbum>>(
|
||||
(ref) {
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'sorted_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
List<Album> sortedAlbum(SortedAlbumRef ref, List<Album> albums) {
|
||||
final albumSortOption = ref.watch(albumSortByOptionsProvider);
|
||||
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
|
||||
return albumSortOption.sortFn(albums, albumSortIsReverse);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sorted_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sortedAlbumHash() => r'350f537324a42ba9e01e50ca3722878ec3a8330c';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
@ProviderFor(sortedAlbum)
|
||||
const sortedAlbumProvider = SortedAlbumFamily();
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
class SortedAlbumFamily extends Family<List<Album>> {
|
||||
/// See also [sortedAlbum].
|
||||
const SortedAlbumFamily();
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
SortedAlbumProvider call(
|
||||
List<Album> albums,
|
||||
) {
|
||||
return SortedAlbumProvider(
|
||||
albums,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SortedAlbumProvider getProviderOverride(
|
||||
covariant SortedAlbumProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.albums,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'sortedAlbumProvider';
|
||||
}
|
||||
|
||||
/// See also [sortedAlbum].
|
||||
class SortedAlbumProvider extends AutoDisposeProvider<List<Album>> {
|
||||
/// See also [sortedAlbum].
|
||||
SortedAlbumProvider(
|
||||
List<Album> albums,
|
||||
) : this._internal(
|
||||
(ref) => sortedAlbum(
|
||||
ref as SortedAlbumRef,
|
||||
albums,
|
||||
),
|
||||
from: sortedAlbumProvider,
|
||||
name: r'sortedAlbumProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$sortedAlbumHash,
|
||||
dependencies: SortedAlbumFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
SortedAlbumFamily._allTransitiveDependencies,
|
||||
albums: albums,
|
||||
);
|
||||
|
||||
SortedAlbumProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.albums,
|
||||
}) : super.internal();
|
||||
|
||||
final List<Album> albums;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
List<Album> Function(SortedAlbumRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: SortedAlbumProvider._internal(
|
||||
(ref) => create(ref as SortedAlbumRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
albums: albums,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeProviderElement<List<Album>> createElement() {
|
||||
return _SortedAlbumProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SortedAlbumProvider && other.albums == albums;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, albums.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin SortedAlbumRef on AutoDisposeProviderRef<List<Album>> {
|
||||
/// The parameter `albums` of this provider.
|
||||
List<Album> get albums;
|
||||
}
|
||||
|
||||
class _SortedAlbumProviderElement
|
||||
extends AutoDisposeProviderElement<List<Album>> with SortedAlbumRef {
|
||||
_SortedAlbumProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
List<Album> get albums => (origin as SortedAlbumProvider).albums;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -1,14 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/album_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
@@ -18,9 +14,7 @@ import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
@@ -28,7 +22,6 @@ final albumServiceProvider = Provider(
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -37,9 +30,6 @@ class AlbumService {
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final BackupService _backupService;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(
|
||||
@@ -47,98 +37,8 @@ class AlbumService {
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
this._backupService,
|
||||
);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds =
|
||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
||||
final List<String> selectedIds =
|
||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
||||
if (selectedIds.isEmpty) {
|
||||
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
||||
if (numLocal > 0) {
|
||||
_syncService.removeAllLocalAlbumsAndAssets();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||
);
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
// iOS and Android device album working principle differ significantly
|
||||
// on iOS, an asset can be in multiple albums
|
||||
// on Android, an asset can only be in exactly one album (folder!) at the same time
|
||||
// thus, on Android, excluding an album can be done by ignoring that album
|
||||
// however, on iOS, it it necessary to load the assets from all excluded
|
||||
// albums and check every asset from any selected album against the set
|
||||
// of excluded assets
|
||||
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
|
||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||
}
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => excludedIds.contains(e.id));
|
||||
_log.info(
|
||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||
);
|
||||
}
|
||||
final hasAll = selectedIds
|
||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||
onDevice.removeWhere((e) => e.isAll);
|
||||
_log.info("'Recents' is selected, keeping all individual albums");
|
||||
}
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
|
||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||
}
|
||||
changes =
|
||||
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
|
||||
_log.info("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(
|
||||
List<AssetPathEntity> albums,
|
||||
List<String> excludedAlbumIds,
|
||||
) async {
|
||||
final Set<String> result = HashSet<String>();
|
||||
for (AssetPathEntity a in albums) {
|
||||
if (excludedAlbumIds.contains(a.id)) {
|
||||
final List<AssetEntity> assets =
|
||||
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
|
||||
result.addAll(assets.map((e) => e.id));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||
@@ -170,7 +70,7 @@ class AlbumService {
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
Future<RemoteAlbum?> createAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets, [
|
||||
Iterable<User> sharedUsers = const [],
|
||||
@@ -184,8 +84,8 @@ class AlbumService {
|
||||
),
|
||||
);
|
||||
if (remote != null) {
|
||||
Album album = await Album.remote(remote);
|
||||
await _db.writeTxn(() => _db.albums.store(album));
|
||||
RemoteAlbum album = await RemoteAlbum.fromDto(remote, _db);
|
||||
await _db.writeTxn(() => _db.remoteAlbums.store(album));
|
||||
return album;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -203,13 +103,16 @@ class AlbumService {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (null ==
|
||||
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||
await _db.remoteAlbums
|
||||
.filter()
|
||||
.nameEqualTo(proposedName)
|
||||
.findFirst()) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(
|
||||
Future<RemoteAlbum?> createAlbumWithGeneratedName(
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
return createAlbum(
|
||||
@@ -221,11 +124,17 @@ class AlbumService {
|
||||
|
||||
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
|
||||
Iterable<Asset> assets,
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
) async {
|
||||
try {
|
||||
final remoteAlbum =
|
||||
await _db.remoteAlbums.where().idEqualTo(album.id).findFirst();
|
||||
if (remoteAlbum == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var response = await _apiService.albumApi.addAssetsToAlbum(
|
||||
album.remoteId!,
|
||||
remoteAlbum.id,
|
||||
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
|
||||
@@ -244,10 +153,10 @@ class AlbumService {
|
||||
}
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await album.assets.update(link: successAssets);
|
||||
final a = await _db.albums.get(album.id);
|
||||
await remoteAlbum.assets.update(link: successAssets);
|
||||
final a = await _db.remoteAlbums.get(remoteAlbum.isarId);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
await _db.remoteAlbums.put(a!);
|
||||
});
|
||||
|
||||
return AddAssetsResponse(
|
||||
@@ -264,11 +173,11 @@ class AlbumService {
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
List<String> sharedUserIds,
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||
);
|
||||
if (result != null) {
|
||||
@@ -276,7 +185,7 @@ class AlbumService {
|
||||
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||
album.shared = result.shared;
|
||||
await _db.writeTxn(() async {
|
||||
await _db.albums.put(album);
|
||||
await _db.remoteAlbums.put(album);
|
||||
await album.sharedUsers.save();
|
||||
});
|
||||
return true;
|
||||
@@ -287,15 +196,15 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool enabled) async {
|
||||
Future<bool> setActivityEnabled(RemoteAlbum album, bool enabled) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
UpdateAlbumDto(isActivityEnabled: enabled),
|
||||
);
|
||||
if (result != null) {
|
||||
album.activityEnabled = enabled;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
await _db.writeTxn(() => _db.remoteAlbums.put(album));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -304,20 +213,20 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
Future<bool> deleteAlbum(RemoteAlbum album) async {
|
||||
try {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
if (album.owner.value?.isarId == userId) {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
await _apiService.albumApi.deleteAlbum(album.id);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
final List<Album> albums =
|
||||
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
|
||||
final List<RemoteAlbum> albums =
|
||||
await _db.remoteAlbums.filter().sharedEqualTo(true).findAll();
|
||||
final List<Asset> existing = [];
|
||||
for (Album a in albums) {
|
||||
for (RemoteAlbum a in albums) {
|
||||
existing.addAll(
|
||||
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||
);
|
||||
@@ -328,7 +237,7 @@ class AlbumService {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||
}
|
||||
} else {
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
await _db.writeTxn(() => _db.remoteAlbums.delete(album.isarId));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -337,9 +246,9 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
Future<bool> leaveAlbum(RemoteAlbum album) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
|
||||
await _apiService.albumApi.removeUserFromAlbum(album.id, "me");
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
@@ -348,21 +257,21 @@ class AlbumService {
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeAssetFromAlbum(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
BulkIdsDto(
|
||||
ids: assets.map((asset) => asset.remoteId!).toList(),
|
||||
),
|
||||
);
|
||||
await _db.writeTxn(() async {
|
||||
await album.assets.update(unlink: assets);
|
||||
final a = await _db.albums.get(album.id);
|
||||
final a = await _db.remoteAlbums.get(album.isarId);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
await _db.remoteAlbums.put(a!);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -373,21 +282,21 @@ class AlbumService {
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
User user,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
user.id,
|
||||
);
|
||||
|
||||
album.sharedUsers.remove(user);
|
||||
await _db.writeTxn(() async {
|
||||
await album.sharedUsers.update(unlink: [user]);
|
||||
final a = await _db.albums.get(album.id);
|
||||
final a = await _db.remoteAlbums.get(album.isarId);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
await _db.remoteAlbums.put(a!);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -398,18 +307,18 @@ class AlbumService {
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(
|
||||
Album album,
|
||||
RemoteAlbum album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
album.id,
|
||||
UpdateAlbumDto(
|
||||
albumName: newAlbumTitle,
|
||||
),
|
||||
);
|
||||
album.name = newAlbumTitle;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
await _db.writeTxn(() => _db.remoteAlbums.put(album));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/extensions/album_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup_album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/etag.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/hash.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class LocalAlbumService {
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
final Logger _log = Logger('LocalAlbumService');
|
||||
|
||||
final Isar _db;
|
||||
final HashService _hashService;
|
||||
final SyncService _syncService;
|
||||
final BackupAlbumService _backupAlbumService;
|
||||
|
||||
LocalAlbumService(
|
||||
this._db,
|
||||
this._hashService,
|
||||
this._syncService,
|
||||
this._backupAlbumService,
|
||||
);
|
||||
|
||||
Future<bool> refreshDeviceAlbums() async =>
|
||||
SyncService.lock.run(_refreshDeviceAlbums);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> _refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
filterOption: FilterOptionGroup(
|
||||
containsPathModified: true,
|
||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||
// title is needed to create Assets
|
||||
imageOption: const FilterOption(needTitle: true),
|
||||
videoOption: const FilterOption(needTitle: true),
|
||||
),
|
||||
);
|
||||
_log.fine("Found ${onDevice.length} device albums");
|
||||
changes = await _syncLocalAlbumAssetsToDb(onDevice);
|
||||
_log.fine("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
_log.fine("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async {
|
||||
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
||||
final inDb = await _db.localAlbums.where().sortById().findAll();
|
||||
final List<Asset> deleteCandidates = [];
|
||||
final List<Asset> existing = [];
|
||||
assert(inDb.isSorted((a, b) => a.id.compareTo(b.id)), "sort!");
|
||||
final bool anyChanges = await diffSortedLists(
|
||||
onDevice,
|
||||
inDb,
|
||||
compare: (AssetPathEntity a, LocalAlbum b) => a.id.compareTo(b.id),
|
||||
both: (AssetPathEntity ape, LocalAlbum album) =>
|
||||
_syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing),
|
||||
onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing),
|
||||
onlySecond: (LocalAlbum a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||
);
|
||||
_log.fine(
|
||||
"Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete",
|
||||
);
|
||||
final (toDelete, toUpdate) =
|
||||
_handleAssetRemoval(deleteCandidates, existing, remote: false);
|
||||
_log.fine(
|
||||
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
|
||||
);
|
||||
|
||||
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.deleteAll(toDelete);
|
||||
await _db.exifInfos.deleteAll(toDelete);
|
||||
await _db.assets.putAll(toUpdate);
|
||||
});
|
||||
_log.info(
|
||||
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
|
||||
);
|
||||
}
|
||||
return anyChanges;
|
||||
}
|
||||
|
||||
/// Accumulates all suitable album assets to the `deleteCandidates` and
|
||||
/// removes the album from the database.
|
||||
Future<void> _removeAlbumFromDb(
|
||||
LocalAlbum album,
|
||||
List<Asset> deleteCandidates,
|
||||
) async {
|
||||
_log.info("Removing local album $album from DB");
|
||||
// delete assets in DB unless they are remote or part of some other album
|
||||
deleteCandidates.addAll(
|
||||
await album.assets.filter().remoteIdIsNull().findAll(),
|
||||
);
|
||||
await album.backup.load();
|
||||
final backupAlbum = album.backup.value;
|
||||
try {
|
||||
final ok = await _db.writeTxn(() async {
|
||||
return await _db.localAlbums.delete(album.isarId) &&
|
||||
(backupAlbum == null ||
|
||||
await _db.backupAlbums.delete(album.isarId));
|
||||
});
|
||||
assert(ok);
|
||||
_log.info("Removed local album $album from DB");
|
||||
} catch (e, stack) {
|
||||
_log.severe("Failed to remove local album $album from DB: $e", stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
||||
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing, {
|
||||
bool? remote,
|
||||
}) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return const ([], []);
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
existing.uniqueConsecutive(compare: Asset.compareById);
|
||||
final (tooAdd, toUpdate, toRemove) = _syncService.diffAssets(
|
||||
existing,
|
||||
deleteCandidates,
|
||||
compare: Asset.compareById,
|
||||
remote: remote,
|
||||
);
|
||||
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
|
||||
return (toRemove.map((e) => e.id).toList(), toUpdate);
|
||||
}
|
||||
|
||||
/// Syncs the device album to the album in the database
|
||||
/// returns `true` if there were any changes
|
||||
/// Accumulates asset candidates to delete and those already existing in DB
|
||||
Future<bool> _syncAlbumInDbAndOnDevice(
|
||||
AssetPathEntity ape,
|
||||
LocalAlbum album,
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing, [
|
||||
bool forceRefresh = false,
|
||||
]) async {
|
||||
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
|
||||
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
|
||||
return false;
|
||||
}
|
||||
if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||
final inDb = await album.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||
.sortByChecksum()
|
||||
.findAll();
|
||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||
final List<Asset> onDevice = await _hashService.getHashedAssets(ape);
|
||||
// _removeDuplicates sorts `onDevice` by checksum
|
||||
_removeDuplicates(onDevice);
|
||||
final (toAdd, toUpdate, toDelete) = _syncService.diffAssets(onDevice, inDb);
|
||||
_log.fine(
|
||||
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
|
||||
);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||
_log.fine(
|
||||
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
|
||||
);
|
||||
deleteCandidates.addAll(toDelete);
|
||||
existing.addAll(existingInDb);
|
||||
album.name = ape.name;
|
||||
album.modifiedAt = ape.lastModified ?? DateTime.now();
|
||||
if (album.thumb.value != null && toDelete.contains(album.thumbnail)) {
|
||||
album.thumb.value = null;
|
||||
}
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updated);
|
||||
await _db.assets.putAll(toUpdate);
|
||||
await album.assets
|
||||
.update(link: existingInDb + updated, unlink: toDelete);
|
||||
album.thumb.value ??= await album.assets.filter().findFirst();
|
||||
await _db.localAlbums.store(album);
|
||||
});
|
||||
_updateETagCount(ape);
|
||||
_log.info("Synced changes of local album ${ape.name} to DB");
|
||||
} on IsarError catch (e, stack) {
|
||||
_log.severe("Failed to update synced album ${ape.name} in DB: $e", stack);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
Future<bool> _hasAssetPathEntityChanged(
|
||||
AssetPathEntity a,
|
||||
LocalAlbum b,
|
||||
) async {
|
||||
final lastKnownTotal =
|
||||
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount ?? 0;
|
||||
final hasSameLastModified = !(Platform.isAndroid && a.isAll) &&
|
||||
(a.lastModified == null ||
|
||||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt));
|
||||
return a.name != b.name ||
|
||||
hasSameLastModified ||
|
||||
await a.assetCountAsync != lastKnownTotal;
|
||||
}
|
||||
|
||||
/// Adds a new album from the device to the database and Accumulates all
|
||||
/// assets already existing in the database to the list of `existing` assets
|
||||
Future<void> _addAlbumFromDevice(
|
||||
AssetPathEntity ape,
|
||||
List<Asset> existing,
|
||||
) async {
|
||||
_log.info("Syncing a new local album to DB: ${ape.name}");
|
||||
|
||||
final assets = await _hashService.getHashedAssets(ape);
|
||||
_removeDuplicates(assets);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
|
||||
_log.info(
|
||||
"${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
|
||||
);
|
||||
await _syncService.upsertAssetsWithExif(updated);
|
||||
existing.addAll(existingInDb);
|
||||
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
||||
final LocalAlbum a = LocalAlbum.fromAssetPathEntity(
|
||||
ape,
|
||||
thumbnail: thumb,
|
||||
assets: existingInDb.followedBy(updated),
|
||||
);
|
||||
try {
|
||||
await _db.writeTxn(() => _db.localAlbums.store(a));
|
||||
_updateETagCount(ape);
|
||||
_log.info("Added a new local album to DB: ${ape.name}");
|
||||
} on IsarError catch (e, stack) {
|
||||
_log.severe("Failed to add new local album ${ape.name} to DB: $e", stack);
|
||||
}
|
||||
await _backupAlbumService.syncWithLocalAlbum(a);
|
||||
}
|
||||
|
||||
/// fast path for common case: only new assets were added to device album
|
||||
/// returns `true` if successfull, else `false`
|
||||
Future<bool> _syncDeviceAlbumFast(
|
||||
AssetPathEntity ape,
|
||||
LocalAlbum album,
|
||||
) async {
|
||||
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
|
||||
return false;
|
||||
}
|
||||
final int totalOnDevice = await ape.assetCountAsync;
|
||||
final int lastKnownTotal =
|
||||
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
|
||||
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
|
||||
? await ape.fetchPathProperties(
|
||||
filterOptionGroup: FilterOptionGroup(
|
||||
updateTimeCond: DateTimeCond(
|
||||
min: album.modifiedAt.add(const Duration(seconds: 1)),
|
||||
max: ape.lastModified ?? DateTime.now(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (modified == null) {
|
||||
return false;
|
||||
}
|
||||
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
|
||||
|
||||
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
||||
return false;
|
||||
}
|
||||
album.modifiedAt = ape.lastModified ?? DateTime.now();
|
||||
_removeDuplicates(newAssets);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updated);
|
||||
await album.assets.update(link: existingInDb + updated);
|
||||
await _db.localAlbums.put(album);
|
||||
});
|
||||
_updateETagCount(ape);
|
||||
_log.info("Fast synced local album ${ape.name} to DB");
|
||||
} on IsarError catch (e, stack) {
|
||||
_log.severe(
|
||||
"Failed to fast sync local album ${ape.name} to DB: $e",
|
||||
stack,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _updateETagCount(AssetPathEntity ape) async {
|
||||
final assetCountOnDevice = await ape.assetCountAsync;
|
||||
return _db.writeTxn(
|
||||
() => _db.eTags.put(
|
||||
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Asset> _removeDuplicates(List<Asset> assets) {
|
||||
final int before = assets.length;
|
||||
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
|
||||
assets.uniqueConsecutive(
|
||||
compare: Asset.compareByOwnerChecksum,
|
||||
onDuplicate: (a, b) =>
|
||||
_log.fine("Ignoring duplicate assets on device:\n$a\n$b"),
|
||||
);
|
||||
final int duplicates = before - assets.length;
|
||||
if (duplicates > 0) {
|
||||
_log.warning("Ignored $duplicates duplicate assets on device");
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// Returns a tuple (existing, updated)
|
||||
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
|
||||
List<Asset> assets,
|
||||
) async {
|
||||
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
|
||||
|
||||
final List<Asset?> inDb = await _db.assets.getAllByOwnerIdChecksum(
|
||||
assets.map((a) => a.ownerId).toInt64List(),
|
||||
assets.map((a) => a.checksum).toList(growable: false),
|
||||
);
|
||||
assert(inDb.length == assets.length);
|
||||
final List<Asset> existing = [], toUpsert = [];
|
||||
for (int i = 0; i < assets.length; i++) {
|
||||
final Asset? b = inDb[i];
|
||||
if (b == null) {
|
||||
toUpsert.add(assets[i]);
|
||||
continue;
|
||||
}
|
||||
if (b.canUpdate(assets[i])) {
|
||||
final updated = b.updatedCopy(assets[i]);
|
||||
assert(updated.id != Isar.autoIncrement);
|
||||
toUpsert.add(updated);
|
||||
} else {
|
||||
existing.add(b);
|
||||
}
|
||||
}
|
||||
assert(existing.length + toUpsert.length == assets.length);
|
||||
return (existing, toUpsert);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
@@ -25,14 +25,14 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
final albums = ref.watch(remoteAlbumsProvider).valueOrNull ?? [];
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
@@ -40,7 +40,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(Album album) async {
|
||||
void addToAlbum(RemoteAlbum album) async {
|
||||
final result = await albumService.addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
/// The asset to add to an album
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final void Function(Album) onAddToAlbum;
|
||||
final List<RemoteAlbum> albums;
|
||||
final List<RemoteAlbum> sharedAlbums;
|
||||
final void Function(RemoteAlbum) onAddToAlbum;
|
||||
final bool enabled;
|
||||
|
||||
const AddToAlbumSliverList({
|
||||
@@ -46,9 +46,11 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: sortedSharedAlbums.length,
|
||||
itemBuilder: (context, index) => AlbumThumbnailListTile(
|
||||
album: sortedSharedAlbums[index],
|
||||
album: sortedSharedAlbums[index] as RemoteAlbum,
|
||||
onTap: enabled
|
||||
? () => onAddToAlbum(sortedSharedAlbums[index])
|
||||
? () => onAddToAlbum(
|
||||
sortedSharedAlbums[index] as RemoteAlbum,
|
||||
)
|
||||
: () {},
|
||||
),
|
||||
),
|
||||
@@ -59,7 +61,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
|
||||
// Build albums list
|
||||
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
|
||||
final album = sortedAlbums[offset];
|
||||
final album = sortedAlbums[offset] as RemoteAlbum;
|
||||
return AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: enabled ? () => onAddToAlbum(album) : () {},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumSortSelector extends ConsumerWidget {
|
||||
final List<AlbumSortMode> sortModes;
|
||||
|
||||
const AlbumSortSelector({required this.sortModes, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumSortMode = ref.watch(albumSortByOptionsProvider);
|
||||
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
|
||||
|
||||
List<PopupMenuEntry<AlbumSortMode>> buildOptions(BuildContext ctx) {
|
||||
{
|
||||
return sortModes.map((option) {
|
||||
final selected = albumSortMode == option;
|
||||
return PopupMenuItem(
|
||||
value: option,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: selected ? context.primaryColor : Colors.transparent,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
option.label.tr(),
|
||||
style: TextStyle(
|
||||
color: selected ? context.primaryColor : null,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
void onSelected(AlbumSortMode mode) {
|
||||
final isAlreadySelected = albumSortMode == mode;
|
||||
// Switch direction
|
||||
if (isAlreadySelected) {
|
||||
return ref
|
||||
.read(albumSortOrderProvider.notifier)
|
||||
.changeSortDirection(!albumSortIsReverse);
|
||||
}
|
||||
return ref.read(albumSortByOptionsProvider.notifier).changeSortMode(mode);
|
||||
}
|
||||
|
||||
return PopupMenuButton(
|
||||
position: PopupMenuPosition.over,
|
||||
itemBuilder: buildOptions,
|
||||
onSelected: onSelected,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: Icon(
|
||||
albumSortIsReverse
|
||||
? Icons.arrow_downward_rounded
|
||||
: Icons.arrow_upward_rounded,
|
||||
size: 14,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
albumSortMode.label.tr(),
|
||||
style: context.textTheme.labelLarge
|
||||
?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/extensions/object_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
|
||||
class AlbumThumbnailCard extends StatelessWidget {
|
||||
final Album album;
|
||||
final Function()? onTap;
|
||||
final bool showAssetCount;
|
||||
final Icon? emptyThumbnailPlaceholder;
|
||||
|
||||
/// Whether or not to show the owner of the album (or "Owned")
|
||||
/// in the subtitle of the album
|
||||
@@ -16,11 +20,11 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
super.key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
this.showAssetCount = true,
|
||||
this.showOwner = false,
|
||||
this.emptyThumbnailPlaceholder,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isDarkTheme = context.isDarkTheme;
|
||||
@@ -29,62 +33,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
builder: (context, constraints) {
|
||||
var cardSize = constraints.maxWidth;
|
||||
|
||||
buildEmptyThumbnail() {
|
||||
return Container(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.no_photography,
|
||||
size: cardSize * .15,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() => ImmichThumbnail(
|
||||
asset: album.thumbnail.value,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
);
|
||||
|
||||
buildAlbumTextRow() {
|
||||
// Add the owner name to the subtitle
|
||||
String? owner;
|
||||
if (showOwner) {
|
||||
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
|
||||
owner = 'album_thumbnail_owned'.tr();
|
||||
} else if (album.ownerName != null) {
|
||||
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
|
||||
}
|
||||
}
|
||||
|
||||
return RichText(
|
||||
overflow: TextOverflow.fade,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
.tr(args: ['${album.assetCount}'])
|
||||
: 'album_thumbnail_card_items'
|
||||
.tr(args: ['${album.assetCount}']),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
if (owner != null)
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Flex(
|
||||
@@ -98,14 +46,46 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: album.thumbnail.value == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(20)),
|
||||
child: album.thumbnail == null
|
||||
// Empty placeholder
|
||||
? Container(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 53, 53, 53)
|
||||
: const Color.fromARGB(
|
||||
255,
|
||||
203,
|
||||
203,
|
||||
203,
|
||||
),
|
||||
),
|
||||
color: isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[50],
|
||||
),
|
||||
child: Center(
|
||||
child: emptyThumbnailPlaceholder ??
|
||||
Icon(
|
||||
Icons.no_photography,
|
||||
size: cardSize * .15,
|
||||
),
|
||||
),
|
||||
)
|
||||
// Thumbnail image
|
||||
: ImmichThumbnail(
|
||||
asset: album.thumbnail,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
padding: const EdgeInsets.only(top: 8.0, left: 8.0),
|
||||
child: SizedBox(
|
||||
width: cardSize,
|
||||
child: Text(
|
||||
@@ -118,7 +98,14 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
buildAlbumTextRow(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: _AlbumTextRow(
|
||||
album: album,
|
||||
showAssetCount: showAssetCount,
|
||||
showOwner: showOwner,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -129,3 +116,55 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumTextRow extends StatelessWidget {
|
||||
final Album album;
|
||||
final bool showAssetCount;
|
||||
|
||||
/// Whether or not to show the owner of the album (or "Owned")
|
||||
/// in the subtitle of the album
|
||||
final bool showOwner;
|
||||
|
||||
const _AlbumTextRow({
|
||||
required this.album,
|
||||
required this.showAssetCount,
|
||||
required this.showOwner,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? owner;
|
||||
|
||||
if (showOwner) {
|
||||
if (album.tryCast<RemoteAlbum>()?.ownerId ==
|
||||
Store.get(StoreKey.currentUser).id) {
|
||||
owner = 'album_thumbnail_owned'.tr();
|
||||
} else if (album.tryCast<RemoteAlbum>()?.ownerName != null) {
|
||||
owner = 'album_thumbnail_shared_by'
|
||||
.tr(args: [(album as RemoteAlbum).ownerName!]);
|
||||
}
|
||||
}
|
||||
|
||||
return Text.rich(
|
||||
overflow: TextOverflow.fade,
|
||||
TextSpan(
|
||||
children: [
|
||||
if (showAssetCount)
|
||||
TextSpan(
|
||||
text: album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
.tr(args: ['${album.assetCount}'])
|
||||
: 'album_thumbnail_card_items'
|
||||
.tr(args: ['${album.assetCount}']),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -16,7 +16,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
final void Function()? onTap;
|
||||
|
||||
@override
|
||||
@@ -61,7 +61,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap ??
|
||||
() {
|
||||
context.pushRoute(AlbumViewerRoute(albumId: album.id));
|
||||
context.pushRoute(RemoteAlbumViewerRoute(albumId: album.isarId));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
@@ -70,7 +70,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: album.thumbnail.value == null
|
||||
child: album.thumbnail == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
final FocusNode titleFocusNode;
|
||||
const AlbumViewerEditableTitle({
|
||||
super.key,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class LibraryNavButton extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Function() onClick;
|
||||
|
||||
const LibraryNavButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onClick,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 180.0,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onClick,
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: context.isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black.withAlpha(200),
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
backgroundColor:
|
||||
context.isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
side: BorderSide(
|
||||
color: context.isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
icon: Icon(
|
||||
icon,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-22
@@ -5,17 +5,17 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
class AlbumViewerAppbar extends HookConsumerWidget
|
||||
class RemoteAlbumViewerAppbar extends HookConsumerWidget
|
||||
implements PreferredSizeWidget {
|
||||
const AlbumViewerAppbar({
|
||||
const RemoteAlbumViewerAppbar({
|
||||
super.key,
|
||||
required this.album,
|
||||
required this.userId,
|
||||
@@ -25,21 +25,20 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
required this.onActivities,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
final String userId;
|
||||
final FocusNode titleFocusNode;
|
||||
final Function(Album album)? onAddPhotos;
|
||||
final Function(Album album)? onAddUsers;
|
||||
final Function(Album album) onActivities;
|
||||
final Function(RemoteAlbum album)? onAddPhotos;
|
||||
final Function(RemoteAlbum album)? onAddUsers;
|
||||
final Function() onActivities;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
final isProcessing = useProcessingOverlay();
|
||||
final comments = album.shared
|
||||
? ref.watch(activityStatisticsProvider(album.remoteId!))
|
||||
: 0;
|
||||
final comments =
|
||||
album.shared ? ref.watch(activityStatisticsProvider(album.id)) : 0;
|
||||
|
||||
deleteAlbum() async {
|
||||
isProcessing.value = true;
|
||||
@@ -51,7 +50,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
context
|
||||
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
|
||||
success =
|
||||
await ref.watch(remoteAlbumsProvider.notifier).deleteAlbum(album);
|
||||
context
|
||||
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
|
||||
}
|
||||
@@ -172,7 +172,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share_rounded),
|
||||
onTap: () {
|
||||
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
|
||||
context.pushRoute(SharedLinkEditRoute(albumId: album.id));
|
||||
context.pop();
|
||||
},
|
||||
title: const Text(
|
||||
@@ -228,9 +228,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
|
||||
Widget buildActivitiesButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onActivities(album);
|
||||
},
|
||||
onPressed: () => onActivities(),
|
||||
icon: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -291,12 +289,11 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
actions: [
|
||||
if (album.shared && (album.activityEnabled || comments != 0))
|
||||
buildActivitiesButton(),
|
||||
if (album.isRemote)
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
onPressed: buildBottomSheet,
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
),
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
onPressed: buildBottomSheet,
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -5,10 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
@@ -16,7 +16,7 @@ import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AlbumOptionsPage extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
|
||||
const AlbumOptionsPage({super.key, required this.album});
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
|
||||
@@ -194,17 +194,17 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
createNonSharedAlbum() async {
|
||||
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
|
||||
var newAlbum = await ref.watch(remoteAlbumsProvider.notifier).createAlbum(
|
||||
ref.watch(albumTitleProvider),
|
||||
selectedAssets.value,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(remoteAlbumsProvider.notifier).getRemoteAlbums();
|
||||
selectedAssets.value = {};
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id));
|
||||
context.replaceRoute(RemoteAlbumViewerRoute(albumId: newAlbum.isarId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
// ignore_for_file: prefer-sliver-prefix
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/sorted_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_sort_selector.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/library_nav_button.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
@@ -17,180 +24,37 @@ class LibraryPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final albums = ref.watch(albumProvider);
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
final albumSortOption = ref.watch(albumSortByOptionsProvider);
|
||||
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
|
||||
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Widget buildSortButton() {
|
||||
return PopupMenuButton(
|
||||
position: PopupMenuPosition.over,
|
||||
itemBuilder: (BuildContext context) {
|
||||
return AlbumSortMode.values
|
||||
.map<PopupMenuEntry<AlbumSortMode>>((option) {
|
||||
final selected = albumSortOption == option;
|
||||
return PopupMenuItem(
|
||||
value: option,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color:
|
||||
selected ? context.primaryColor : Colors.transparent,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
option.label.tr(),
|
||||
style: TextStyle(
|
||||
color: selected ? context.primaryColor : null,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
onSelected: (AlbumSortMode value) {
|
||||
final selected = albumSortOption == value;
|
||||
// Switch direction
|
||||
if (selected) {
|
||||
ref
|
||||
.read(albumSortOrderProvider.notifier)
|
||||
.changeSortDirection(!albumSortIsReverse);
|
||||
} else {
|
||||
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: Icon(
|
||||
albumSortIsReverse
|
||||
? Icons.arrow_downward_rounded
|
||||
: Icons.arrow_upward_rounded,
|
||||
size: 14,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
albumSortOption.label.tr(),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: _LibraryAppBar(),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_SilverLibraryNavigationButtons(),
|
||||
_SilverLibraryRemoteAlbumHeader(),
|
||||
_SilverLibraryRemoteAlbumGrid(),
|
||||
_SilverLibraryLocalAlbumHeader(),
|
||||
_SilverLibraryLocalAlbumGrid(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildCreateAlbumButton() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var cardSize = constraints.maxWidth;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () =>
|
||||
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 53, 53, 53)
|
||||
: const Color.fromARGB(255, 203, 203, 203),
|
||||
),
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.add_rounded,
|
||||
size: 28,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Text(
|
||||
'library_page_new_album',
|
||||
style: context.textTheme.labelLarge,
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLibraryNavButton(
|
||||
String label,
|
||||
IconData icon,
|
||||
Function() onClick,
|
||||
) {
|
||||
return Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onClick,
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: context.isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black.withAlpha(200),
|
||||
),
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
side: BorderSide(
|
||||
color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
icon: Icon(
|
||||
icon,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final remote = albums.where((a) => a.isRemote).toList();
|
||||
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
|
||||
final local = albums.where((a) => a.isLocal).toList();
|
||||
|
||||
Widget? shareTrashButton() {
|
||||
return trashEnabled
|
||||
class _LibraryAppBar extends ImmichAppBar {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
return ImmichAppBar(
|
||||
action: trashEnabled
|
||||
? InkWell(
|
||||
onTap: () => context.pushRoute(const TrashRoute()),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
@@ -199,133 +63,148 @@ class LibraryPage extends HookConsumerWidget {
|
||||
size: 25,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: ImmichAppBar(
|
||||
action: shareTrashButton(),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
top: 24.0,
|
||||
bottom: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
buildLibraryNavButton(
|
||||
"library_page_favorites".tr(), Icons.favorite_border, () {
|
||||
context.navigateTo(const FavoritesRoute());
|
||||
}),
|
||||
const SizedBox(width: 12.0),
|
||||
buildLibraryNavButton(
|
||||
"library_page_archive".tr(), Icons.archive_outlined, () {
|
||||
context.navigateTo(const ArchiveRoute());
|
||||
}),
|
||||
],
|
||||
),
|
||||
class _SilverLibraryNavigationButtons extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
LibraryNavButton(
|
||||
label: "library_page_favorites",
|
||||
icon: Icons.favorite_border,
|
||||
onClick: () => context.navigateTo(const FavoritesRoute()),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 12.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
bottom: 20.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'library_page_albums',
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).tr(),
|
||||
buildSortButton(),
|
||||
],
|
||||
),
|
||||
LibraryNavButton(
|
||||
label: "library_page_archive",
|
||||
icon: Icons.archive_outlined,
|
||||
onClick: () => context.navigateTo(const ArchiveRoute()),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 250,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: .7,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: sorted.length + 1,
|
||||
(context, index) {
|
||||
if (index == 0) {
|
||||
return buildCreateAlbumButton();
|
||||
}
|
||||
|
||||
return AlbumThumbnailCard(
|
||||
album: sorted[index - 1],
|
||||
onTap: () => context.pushRoute(
|
||||
AlbumViewerRoute(
|
||||
albumId: sorted[index - 1].id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 12.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
bottom: 20.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'library_page_device_albums',
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 250,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: .7,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: local.length,
|
||||
(context, index) => AlbumThumbnailCard(
|
||||
album: local[index],
|
||||
onTap: () => context.pushRoute(
|
||||
AlbumViewerRoute(
|
||||
albumId: local[index].id,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SilverLibraryRemoteAlbumHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'library_page_albums',
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).tr(),
|
||||
const AlbumSortSelector(sortModes: AlbumSortMode.values),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SilverLibraryRemoteAlbumGrid extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final remoteAlbums = ref.watch(remoteAlbumsProvider);
|
||||
final remoteSorted =
|
||||
ref.watch(sortedAlbumProvider(remoteAlbums.valueOrNull ?? []));
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 250,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: .7,
|
||||
),
|
||||
itemCount: remoteSorted.length + 1,
|
||||
itemBuilder: (ctx, index) {
|
||||
if (index == 0) {
|
||||
Album placeholder = LocalAlbum(
|
||||
id: 'Placeholder',
|
||||
name: 'library_page_new_album'.tr(),
|
||||
modifiedAt: DateTime.now(),
|
||||
);
|
||||
return AlbumThumbnailCard(
|
||||
album: placeholder,
|
||||
showAssetCount: false,
|
||||
emptyThumbnailPlaceholder: Icon(
|
||||
Icons.add_rounded,
|
||||
size: 28,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
onTap: () =>
|
||||
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
|
||||
);
|
||||
}
|
||||
final remoteAlbum = remoteSorted[index - 1];
|
||||
|
||||
return AlbumThumbnailCard(
|
||||
album: remoteAlbum,
|
||||
onTap: () => context
|
||||
.pushRoute(RemoteAlbumViewerRoute(albumId: remoteAlbum.isarId)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SilverLibraryLocalAlbumHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
|
||||
child: Text(
|
||||
'library_page_device_albums',
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SilverLibraryLocalAlbumGrid extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final localAlbums = ref.watch(localAlbumsProvider).valueOrNull ?? [];
|
||||
final localWithoutRecents =
|
||||
localAlbums.where((e) => e.id != LocalAlbum.isAllId).toList();
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 250,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: .7,
|
||||
),
|
||||
itemCount: localWithoutRecents.length,
|
||||
itemBuilder: (ctx, index) => AlbumThumbnailCard(
|
||||
album: localWithoutRecents[index],
|
||||
onTap: () => context.pushRoute(
|
||||
LocalAlbumViewerRoute(album: localWithoutRecents[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LocalAlbumViewerPage extends HookConsumerWidget {
|
||||
final LocalAlbum album;
|
||||
final bool selectEnabled;
|
||||
|
||||
const LocalAlbumViewerPage({
|
||||
super.key,
|
||||
required this.album,
|
||||
this.selectEnabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: MultiselectGrid(
|
||||
topWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
|
||||
child: Text(
|
||||
album.name,
|
||||
style: context.textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
renderListProvider: localAlbumRenderlistProvider(album.isarId),
|
||||
selectedEnabled: selectEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-28
@@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
@@ -17,9 +18,8 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/remote_album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
@@ -28,15 +28,15 @@ import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AlbumViewerPage extends HookConsumerWidget {
|
||||
class RemoteAlbumViewerPage extends HookConsumerWidget {
|
||||
final int albumId;
|
||||
|
||||
const AlbumViewerPage({super.key, required this.albumId});
|
||||
const RemoteAlbumViewerPage({super.key, required this.albumId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
FocusNode titleFocusNode = useFocusNode();
|
||||
final album = ref.watch(albumWatcher(albumId));
|
||||
final album = ref.watch(remoteAlbumWatcher(albumId));
|
||||
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
|
||||
ref.listen(currentAlbumProvider, (_, __) {});
|
||||
album.whenData(
|
||||
@@ -67,7 +67,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
/// Find out if the assets in album exist on the device
|
||||
/// If they exist, add to selected asset state to show they are already selected.
|
||||
void onAddPhotosPressed(Album albumInfo) async {
|
||||
void onAddPhotosPressed(RemoteAlbum albumInfo) async {
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await context.pushRoute<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
@@ -90,7 +90,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void onAddUsersPressed(Album album) async {
|
||||
void onAddUsersPressed(RemoteAlbum album) async {
|
||||
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
|
||||
SelectAdditionalUserForSharingRoute(album: album),
|
||||
);
|
||||
@@ -106,7 +106,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildControlButton(Album album) {
|
||||
Widget buildControlButton(RemoteAlbum album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
|
||||
child: SizedBox(
|
||||
@@ -131,16 +131,16 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTitle(Album album) {
|
||||
Widget buildTitle(RemoteAlbum album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
|
||||
child: userId == album.ownerId && album.isRemote
|
||||
child: userId == album.ownerId
|
||||
? AlbumViewerEditableTitle(
|
||||
album: album,
|
||||
titleFocusNode: titleFocusNode,
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
padding: const EdgeInsets.only(left: 8.0, bottom: 24),
|
||||
child: Text(
|
||||
album.name,
|
||||
style: context.textTheme.headlineMedium,
|
||||
@@ -149,7 +149,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAlbumDateRange(Album album) {
|
||||
Widget buildAlbumDateRange(RemoteAlbum album) {
|
||||
final DateTime? startDate = album.startDate;
|
||||
final DateTime? endDate = album.endDate;
|
||||
|
||||
@@ -183,7 +183,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSharedUserIconsRow(Album album) {
|
||||
Widget buildSharedUserIconsRow(RemoteAlbum album) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
|
||||
child: SizedBox(
|
||||
@@ -207,49 +207,42 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildHeader(Album album) {
|
||||
Widget buildHeader(RemoteAlbum album) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitle(album),
|
||||
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
|
||||
if (album.shared) buildSharedUserIconsRow(album),
|
||||
if (album.assets.isNotEmpty) buildAlbumDateRange(album),
|
||||
if (album.shared && album.sharedUsers.isNotEmpty)
|
||||
buildSharedUserIconsRow(album),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
onActivitiesPressed(Album album) {
|
||||
if (album.remoteId != null) {
|
||||
context.pushRoute(
|
||||
const ActivitiesRoute(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: ref.watch(multiselectProvider)
|
||||
? null
|
||||
: album.when(
|
||||
data: (data) => AlbumViewerAppbar(
|
||||
data: (data) => RemoteAlbumViewerAppbar(
|
||||
titleFocusNode: titleFocusNode,
|
||||
album: data,
|
||||
userId: userId,
|
||||
onAddPhotos: onAddPhotosPressed,
|
||||
onAddUsers: onAddUsersPressed,
|
||||
onActivities: onActivitiesPressed,
|
||||
onActivities: () => context.pushRoute(const ActivitiesRoute()),
|
||||
),
|
||||
error: (error, stackTrace) => AppBar(title: const Text("Error")),
|
||||
loading: () => AppBar(),
|
||||
),
|
||||
body: album.widgetWhen(
|
||||
onData: (data) => MultiselectGrid(
|
||||
renderListProvider: albumRenderlistProvider(albumId),
|
||||
renderListProvider: remoteAlbumRenderlistProvider(albumId),
|
||||
topWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildHeader(data),
|
||||
if (data.isRemote) buildControlButton(data),
|
||||
buildControlButton(data),
|
||||
],
|
||||
),
|
||||
onRemoveFromAlbum: onRemoveFromAlbumPressed,
|
||||
@@ -5,14 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
||||
@RoutePage<List<String>?>()
|
||||
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
|
||||
const SelectAdditionalUserForSharingPage({super.key, required this.album});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
@@ -51,7 +52,9 @@ class SharingPage extends HookConsumerWidget {
|
||||
album: sharedAlbums[index],
|
||||
showOwner: true,
|
||||
onTap: () => context.pushRoute(
|
||||
AlbumViewerRoute(albumId: sharedAlbums[index].id),
|
||||
RemoteAlbumViewerRoute(
|
||||
albumId: sharedAlbums[index].isarId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -65,7 +68,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
final album = sharedAlbums[index];
|
||||
final album = sharedAlbums[index] as RemoteAlbum;
|
||||
final isOwner = album.ownerId == userId;
|
||||
|
||||
return ListTile(
|
||||
@@ -73,7 +76,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: ImmichThumbnail(
|
||||
asset: album.thumbnail.value,
|
||||
asset: album.thumbnail,
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
@@ -99,8 +102,11 @@ class SharingPage extends HookConsumerWidget {
|
||||
style: context.textTheme.bodyMedium,
|
||||
)
|
||||
: null,
|
||||
onTap: () => context
|
||||
.pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)),
|
||||
onTap: () => context.pushRoute(
|
||||
RemoteAlbumViewerRoute(
|
||||
albumId: sharedAlbums[index].isarId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: sharedAlbums.length,
|
||||
|
||||
@@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album_service.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -14,7 +15,7 @@ import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
final ImageViewerService _imageViewerService;
|
||||
final ShareService _shareService;
|
||||
final AlbumService _albumService;
|
||||
final LocalAlbumService _albumService;
|
||||
|
||||
ImageViewerStateNotifier(
|
||||
this._imageViewerService,
|
||||
@@ -83,6 +84,6 @@ final imageViewerStateProvider =
|
||||
((ref) => ImageViewerStateNotifier(
|
||||
ref.watch(imageViewerServiceProvider),
|
||||
ref.watch(shareServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(localAlbumServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/object_extensions.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
@@ -39,11 +41,15 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
final comments = album != null &&
|
||||
album.remoteId != null &&
|
||||
asset.remoteId != null
|
||||
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
|
||||
: 0;
|
||||
final comments =
|
||||
album != null && album is RemoteAlbum && asset.remoteId != null
|
||||
? ref.watch(
|
||||
activityStatisticsProvider(
|
||||
album.tryCast<RemoteAlbum>()!.id,
|
||||
asset.remoteId,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
Widget buildFavoriteButton(a) {
|
||||
return IconButton(
|
||||
@@ -171,7 +177,8 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
|
||||
buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
|
||||
if (album != null && album.shared) buildActivitiesButton(),
|
||||
if (album != null && album is RemoteAlbum && album.shared)
|
||||
buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||
@@ -351,7 +352,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
handleActivities() {
|
||||
if (album != null && album.shared && album.remoteId != null) {
|
||||
if (album != null && album is RemoteAlbum && album.shared) {
|
||||
context.pushRoute(const ActivitiesRoute());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,26 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/main.dart';
|
||||
import 'package:immich_mobile/modules/album/services/local_album.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup_album.service.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/device_asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/hash.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider_ios/path_provider_ios.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
@@ -346,48 +350,43 @@ class BackgroundService {
|
||||
AppSettingsService settingService = AppSettingsService();
|
||||
BackupService backupService = BackupService(apiService, db, settingService);
|
||||
AppSettingsService settingsService = AppSettingsService();
|
||||
|
||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||
if (selectedAlbums.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
HashService hashService = HashService(db, this);
|
||||
SyncService syncService = SyncService(db);
|
||||
BackupAlbumService backupAlbumService = BackupAlbumService(db);
|
||||
LocalAlbumService localAlbumService =
|
||||
LocalAlbumService(db, hashService, syncService, backupAlbumService);
|
||||
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
|
||||
do {
|
||||
await localAlbumService.refreshDeviceAlbums();
|
||||
|
||||
final idsToBackup = await db.deviceAssets
|
||||
.filter()
|
||||
.backupSelectionEqualTo(BackupSelection.select)
|
||||
.idProperty()
|
||||
.findAll();
|
||||
final localAssetsToBackup = await db.assets
|
||||
.where()
|
||||
.remoteIdIsNull()
|
||||
.filter()
|
||||
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
|
||||
.findAll();
|
||||
|
||||
final toUpload =
|
||||
await backupService.remoteAlreadyUploaded(localAssetsToBackup);
|
||||
if (toUpload.isEmpty) {
|
||||
debugPrint("No Asset On Device - Abort Backup Process");
|
||||
return false;
|
||||
}
|
||||
|
||||
final bool backupOk = await _runBackup(
|
||||
backupService,
|
||||
settingsService,
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
toUpload.toList(),
|
||||
);
|
||||
if (backupOk) {
|
||||
await Store.delete(StoreKey.backupFailedSince);
|
||||
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
db.writeTxnSync(() {
|
||||
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
|
||||
? a.lastBackup
|
||||
: b.lastBackup;
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
||||
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
||||
);
|
||||
db.backupAlbums.deleteAllSync(toDelete);
|
||||
db.backupAlbums.putAllSync(toUpsert);
|
||||
});
|
||||
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
||||
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
@@ -402,8 +401,7 @@ class BackgroundService {
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService,
|
||||
AppSettingsService settingsService,
|
||||
List<BackupAlbum> selectedAlbums,
|
||||
List<BackupAlbum> excludedAlbums,
|
||||
List<Asset> toUpload,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||
final bool notifyTotalProgress = settingsService
|
||||
@@ -411,32 +409,10 @@ class BackgroundService {
|
||||
final bool notifySingleProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
if (_canceledBySystem || toUpload.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
|
||||
try {
|
||||
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||
} catch (e) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_connection_failed_message".tr(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
_assetsToUploadCount = toUpload.length;
|
||||
_uploadedAssetsCount = 0;
|
||||
_updateNotification(
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class AvailableAlbum {
|
||||
final AssetPathEntity albumEntity;
|
||||
final DateTime? lastBackup;
|
||||
final Uint8List? thumbnailData;
|
||||
AvailableAlbum({
|
||||
required this.albumEntity,
|
||||
this.lastBackup,
|
||||
this.thumbnailData,
|
||||
});
|
||||
|
||||
AvailableAlbum copyWith({
|
||||
AssetPathEntity? albumEntity,
|
||||
DateTime? lastBackup,
|
||||
Uint8List? thumbnailData,
|
||||
}) {
|
||||
return AvailableAlbum(
|
||||
albumEntity: albumEntity ?? this.albumEntity,
|
||||
lastBackup: lastBackup ?? this.lastBackup,
|
||||
thumbnailData: thumbnailData ?? this.thumbnailData,
|
||||
);
|
||||
}
|
||||
|
||||
String get name => albumEntity.name;
|
||||
|
||||
Future<int> get assetCount => albumEntity.assetCountAsync;
|
||||
|
||||
String get id => albumEntity.id;
|
||||
|
||||
bool get isAll => albumEntity.isAll;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is AvailableAlbum && other.albumEntity == albumEntity;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => albumEntity.hashCode;
|
||||
}
|
||||
@@ -1,22 +1,50 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'backup_album.model.g.dart';
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class BackupAlbum {
|
||||
String id;
|
||||
DateTime lastBackup;
|
||||
@Enumerated(EnumType.ordinal)
|
||||
BackupSelection selection;
|
||||
|
||||
BackupAlbum(this.id, this.lastBackup, this.selection);
|
||||
|
||||
Id get isarId => fastHash(id);
|
||||
}
|
||||
|
||||
enum BackupSelection {
|
||||
none,
|
||||
select,
|
||||
exclude;
|
||||
}
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class BackupAlbum {
|
||||
Id get isarId => fastHash(id);
|
||||
String id;
|
||||
@enumerated
|
||||
BackupSelection selection;
|
||||
|
||||
static const albumLinkId = 'album';
|
||||
final album = IsarLink<LocalAlbum>();
|
||||
|
||||
BackupAlbum({
|
||||
required this.id,
|
||||
this.selection = BackupSelection.none,
|
||||
});
|
||||
|
||||
BackupAlbum copyWith({
|
||||
String? id,
|
||||
BackupSelection? selection,
|
||||
}) {
|
||||
return BackupAlbum(
|
||||
id: id ?? this.id,
|
||||
selection: selection ?? this.selection,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'BackupAlbum(id: $id, selection: $selection)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BackupAlbum other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id && other.selection == selection;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ selection.hashCode;
|
||||
}
|
||||
|
||||
+129
-108
@@ -17,16 +17,16 @@ const BackupAlbumSchema = CollectionSchema(
|
||||
name: r'BackupAlbum',
|
||||
id: 8308487201128361847,
|
||||
properties: {
|
||||
r'id': PropertySchema(
|
||||
r'hashCode': PropertySchema(
|
||||
id: 0,
|
||||
name: r'hashCode',
|
||||
type: IsarType.long,
|
||||
),
|
||||
r'id': PropertySchema(
|
||||
id: 1,
|
||||
name: r'id',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'lastBackup': PropertySchema(
|
||||
id: 1,
|
||||
name: r'lastBackup',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'selection': PropertySchema(
|
||||
id: 2,
|
||||
name: r'selection',
|
||||
@@ -40,7 +40,14 @@ const BackupAlbumSchema = CollectionSchema(
|
||||
deserializeProp: _backupAlbumDeserializeProp,
|
||||
idName: r'isarId',
|
||||
indexes: {},
|
||||
links: {},
|
||||
links: {
|
||||
r'album': LinkSchema(
|
||||
id: 4803574038667272895,
|
||||
name: r'album',
|
||||
target: r'LocalAlbum',
|
||||
single: true,
|
||||
)
|
||||
},
|
||||
embeddedSchemas: {},
|
||||
getId: _backupAlbumGetId,
|
||||
getLinks: _backupAlbumGetLinks,
|
||||
@@ -64,8 +71,8 @@ void _backupAlbumSerialize(
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeString(offsets[0], object.id);
|
||||
writer.writeDateTime(offsets[1], object.lastBackup);
|
||||
writer.writeLong(offsets[0], object.hashCode);
|
||||
writer.writeString(offsets[1], object.id);
|
||||
writer.writeByte(offsets[2], object.selection.index);
|
||||
}
|
||||
|
||||
@@ -76,10 +83,10 @@ BackupAlbum _backupAlbumDeserialize(
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = BackupAlbum(
|
||||
reader.readString(offsets[0]),
|
||||
reader.readDateTime(offsets[1]),
|
||||
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
|
||||
BackupSelection.none,
|
||||
id: reader.readString(offsets[1]),
|
||||
selection:
|
||||
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
|
||||
BackupSelection.none,
|
||||
);
|
||||
return object;
|
||||
}
|
||||
@@ -92,9 +99,9 @@ P _backupAlbumDeserializeProp<P>(
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readString(offset)) as P;
|
||||
return (reader.readLong(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
return (reader.readString(offset)) as P;
|
||||
case 2:
|
||||
return (_BackupAlbumselectionValueEnumMap[
|
||||
reader.readByteOrNull(offset)] ??
|
||||
@@ -120,11 +127,13 @@ Id _backupAlbumGetId(BackupAlbum object) {
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
|
||||
return [];
|
||||
return [object.album];
|
||||
}
|
||||
|
||||
void _backupAlbumAttach(
|
||||
IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
|
||||
IsarCollection<dynamic> col, Id id, BackupAlbum object) {
|
||||
object.album.attach(col, col.isar.collection<LocalAlbum>(), r'album', id);
|
||||
}
|
||||
|
||||
extension BackupAlbumQueryWhereSort
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
|
||||
@@ -209,6 +218,61 @@ extension BackupAlbumQueryWhere
|
||||
|
||||
extension BackupAlbumQueryFilter
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> hashCodeEqualTo(
|
||||
int value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'hashCode',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
hashCodeGreaterThan(
|
||||
int value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'hashCode',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
hashCodeLessThan(
|
||||
int value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'hashCode',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> hashCodeBetween(
|
||||
int lower,
|
||||
int upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'hashCode',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEqualTo(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
@@ -393,62 +457,6 @@ extension BackupAlbumQueryFilter
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupEqualTo(DateTime value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'lastBackup',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupGreaterThan(
|
||||
DateTime value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'lastBackup',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupLessThan(
|
||||
DateTime value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'lastBackup',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupBetween(
|
||||
DateTime lower,
|
||||
DateTime upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'lastBackup',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectionEqualTo(BackupSelection value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
@@ -510,10 +518,35 @@ extension BackupAlbumQueryObject
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
|
||||
|
||||
extension BackupAlbumQueryLinks
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> album(
|
||||
FilterQuery<LocalAlbum> q) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.link(q, r'album');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> albumIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.linkLength(r'album', 0, true, 0, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQuerySortBy
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QSortBy> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByHashCode() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'hashCode', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByHashCodeDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'hashCode', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
@@ -526,18 +559,6 @@ extension BackupAlbumQuerySortBy
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackup() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackupDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelection() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'selection', Sort.asc);
|
||||
@@ -553,6 +574,18 @@ extension BackupAlbumQuerySortBy
|
||||
|
||||
extension BackupAlbumQuerySortThenBy
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QSortThenBy> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByHashCode() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'hashCode', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByHashCodeDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'hashCode', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
@@ -577,18 +610,6 @@ extension BackupAlbumQuerySortThenBy
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackup() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackupDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelection() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'selection', Sort.asc);
|
||||
@@ -604,6 +625,12 @@ extension BackupAlbumQuerySortThenBy
|
||||
|
||||
extension BackupAlbumQueryWhereDistinct
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByHashCode() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'hashCode');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctById(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
@@ -611,12 +638,6 @@ extension BackupAlbumQueryWhereDistinct
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByLastBackup() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'lastBackup');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctBySelection() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'selection');
|
||||
@@ -632,15 +653,15 @@ extension BackupAlbumQueryProperty
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
|
||||
QueryBuilder<BackupAlbum, int, QQueryOperations> hashCodeProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
return query.addPropertyName(r'hashCode');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, DateTime, QQueryOperations> lastBackupProperty() {
|
||||
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'lastBackup');
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
|
||||
class BackupAlbumState {
|
||||
final List<BackupAlbum> selectedBackupAlbums;
|
||||
final List<BackupAlbum> excludedBackupAlbums;
|
||||
|
||||
const BackupAlbumState({
|
||||
required this.selectedBackupAlbums,
|
||||
required this.excludedBackupAlbums,
|
||||
});
|
||||
|
||||
BackupAlbumState copyWith({
|
||||
List<BackupAlbum>? selectedBackupAlbums,
|
||||
List<BackupAlbum>? excludedBackupAlbums,
|
||||
}) {
|
||||
return BackupAlbumState(
|
||||
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
||||
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BackupAlbumState(selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BackupAlbumState other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
||||
listEquals(other.excludedBackupAlbums, excludedBackupAlbums);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
selectedBackupAlbums.hashCode ^ excludedBackupAlbums.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
class BackupSetting {
|
||||
final bool autoBackup;
|
||||
final bool backgroundBackup;
|
||||
final bool backupRequireWifi;
|
||||
final bool backupRequireCharging;
|
||||
final int backupTriggerDelay;
|
||||
|
||||
const BackupSetting({
|
||||
required this.autoBackup,
|
||||
required this.backgroundBackup,
|
||||
required this.backupRequireWifi,
|
||||
required this.backupRequireCharging,
|
||||
required this.backupTriggerDelay,
|
||||
});
|
||||
|
||||
BackupSetting copyWith({
|
||||
bool? autoBackup,
|
||||
bool? backgroundBackup,
|
||||
bool? backupRequireWifi,
|
||||
bool? backupRequireCharging,
|
||||
int? backupTriggerDelay,
|
||||
}) {
|
||||
return BackupSetting(
|
||||
autoBackup: autoBackup ?? this.autoBackup,
|
||||
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
|
||||
backupRequireCharging:
|
||||
backupRequireCharging ?? this.backupRequireCharging,
|
||||
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackupSettings(autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BackupSetting other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.autoBackup == autoBackup &&
|
||||
other.backgroundBackup == backgroundBackup &&
|
||||
other.backupRequireWifi == backupRequireWifi &&
|
||||
other.backupRequireCharging == backupRequireCharging &&
|
||||
other.backupTriggerDelay == backupTriggerDelay;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return autoBackup.hashCode ^
|
||||
backgroundBackup.hashCode ^
|
||||
backupRequireWifi.hashCode ^
|
||||
backupRequireCharging.hashCode ^
|
||||
backupTriggerDelay.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
|
||||
|
||||
enum BackUpProgressEnum {
|
||||
idle,
|
||||
@@ -19,144 +12,60 @@ enum BackUpProgressEnum {
|
||||
class BackUpState {
|
||||
// enum
|
||||
final BackUpProgressEnum backupProgress;
|
||||
final List<String> allAssetsInDatabase;
|
||||
final double progressInPercentage;
|
||||
final double iCloudDownloadProgress;
|
||||
final CancellationToken cancelToken;
|
||||
final ServerDiskInfo serverInfo;
|
||||
final bool autoBackup;
|
||||
final bool backgroundBackup;
|
||||
final bool backupRequireWifi;
|
||||
final bool backupRequireCharging;
|
||||
final int backupTriggerDelay;
|
||||
|
||||
/// All available albums on the device
|
||||
final List<AvailableAlbum> availableAlbums;
|
||||
final Set<AvailableAlbum> selectedBackupAlbums;
|
||||
final Set<AvailableAlbum> excludedBackupAlbums;
|
||||
|
||||
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
||||
final Set<AssetEntity> allUniqueAssets;
|
||||
|
||||
/// All assets from the selected albums that have been backup
|
||||
final Set<String> selectedAlbumsBackupAssetsIds;
|
||||
|
||||
// Current Backup Asset
|
||||
final CurrentUploadAsset currentUploadAsset;
|
||||
|
||||
const BackUpState({
|
||||
required this.backupProgress,
|
||||
required this.allAssetsInDatabase,
|
||||
required this.progressInPercentage,
|
||||
required this.iCloudDownloadProgress,
|
||||
required this.cancelToken,
|
||||
required this.serverInfo,
|
||||
required this.autoBackup,
|
||||
required this.backgroundBackup,
|
||||
required this.backupRequireWifi,
|
||||
required this.backupRequireCharging,
|
||||
required this.backupTriggerDelay,
|
||||
required this.availableAlbums,
|
||||
required this.selectedBackupAlbums,
|
||||
required this.excludedBackupAlbums,
|
||||
required this.allUniqueAssets,
|
||||
required this.selectedAlbumsBackupAssetsIds,
|
||||
required this.currentUploadAsset,
|
||||
});
|
||||
|
||||
BackUpState copyWith({
|
||||
BackUpProgressEnum? backupProgress,
|
||||
List<String>? allAssetsInDatabase,
|
||||
double? progressInPercentage,
|
||||
double? iCloudDownloadProgress,
|
||||
CancellationToken? cancelToken,
|
||||
ServerDiskInfo? serverInfo,
|
||||
bool? autoBackup,
|
||||
bool? backgroundBackup,
|
||||
bool? backupRequireWifi,
|
||||
bool? backupRequireCharging,
|
||||
int? backupTriggerDelay,
|
||||
List<AvailableAlbum>? availableAlbums,
|
||||
Set<AvailableAlbum>? selectedBackupAlbums,
|
||||
Set<AvailableAlbum>? excludedBackupAlbums,
|
||||
Set<AssetEntity>? allUniqueAssets,
|
||||
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||
CurrentUploadAsset? currentUploadAsset,
|
||||
}) {
|
||||
return BackUpState(
|
||||
backupProgress: backupProgress ?? this.backupProgress,
|
||||
allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
|
||||
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
||||
iCloudDownloadProgress:
|
||||
iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
serverInfo: serverInfo ?? this.serverInfo,
|
||||
autoBackup: autoBackup ?? this.autoBackup,
|
||||
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
|
||||
backupRequireCharging:
|
||||
backupRequireCharging ?? this.backupRequireCharging,
|
||||
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
|
||||
availableAlbums: availableAlbums ?? this.availableAlbums,
|
||||
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
||||
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
||||
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
|
||||
selectedAlbumsBackupAssetsIds:
|
||||
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
|
||||
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||
return 'BackUpState(backupProgress: $backupProgress, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BackUpState other) {
|
||||
if (identical(this, other)) return true;
|
||||
final collectionEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other.backupProgress == backupProgress &&
|
||||
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
|
||||
other.progressInPercentage == progressInPercentage &&
|
||||
other.iCloudDownloadProgress == iCloudDownloadProgress &&
|
||||
other.cancelToken == cancelToken &&
|
||||
other.serverInfo == serverInfo &&
|
||||
other.autoBackup == autoBackup &&
|
||||
other.backgroundBackup == backgroundBackup &&
|
||||
other.backupRequireWifi == backupRequireWifi &&
|
||||
other.backupRequireCharging == backupRequireCharging &&
|
||||
other.backupTriggerDelay == backupTriggerDelay &&
|
||||
collectionEquals(other.availableAlbums, availableAlbums) &&
|
||||
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
||||
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
|
||||
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
|
||||
collectionEquals(
|
||||
other.selectedAlbumsBackupAssetsIds,
|
||||
selectedAlbumsBackupAssetsIds,
|
||||
) &&
|
||||
other.currentUploadAsset == currentUploadAsset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return backupProgress.hashCode ^
|
||||
allAssetsInDatabase.hashCode ^
|
||||
progressInPercentage.hashCode ^
|
||||
iCloudDownloadProgress.hashCode ^
|
||||
cancelToken.hashCode ^
|
||||
serverInfo.hashCode ^
|
||||
autoBackup.hashCode ^
|
||||
backgroundBackup.hashCode ^
|
||||
backupRequireWifi.hashCode ^
|
||||
backupRequireCharging.hashCode ^
|
||||
backupTriggerDelay.hashCode ^
|
||||
availableAlbums.hashCode ^
|
||||
selectedBackupAlbums.hashCode ^
|
||||
excludedBackupAlbums.hashCode ^
|
||||
allUniqueAssets.hashCode ^
|
||||
selectedAlbumsBackupAssetsIds.hashCode ^
|
||||
currentUploadAsset.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class DeviceAssetState {
|
||||
final int uniqueAssetsToBackup;
|
||||
final int backedUpAssets;
|
||||
|
||||
const DeviceAssetState({
|
||||
required this.uniqueAssetsToBackup,
|
||||
required this.backedUpAssets,
|
||||
});
|
||||
|
||||
int get assetsRemaining => uniqueAssetsToBackup - backedUpAssets;
|
||||
|
||||
DeviceAssetState copyWith({
|
||||
int? uniqueAssetsToBackup,
|
||||
int? backedUpAssets,
|
||||
}) {
|
||||
return DeviceAssetState(
|
||||
uniqueAssetsToBackup: uniqueAssetsToBackup ?? this.uniqueAssetsToBackup,
|
||||
backedUpAssets: backedUpAssets ?? this.backedUpAssets,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'DeviceAssetState(uniqueAssetsToBackup: $uniqueAssetsToBackup, backedUpAssets: $backedUpAssets)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DeviceAssetState other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.uniqueAssetsToBackup == uniqueAssetsToBackup &&
|
||||
other.backedUpAssets == backedUpAssets;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => uniqueAssetsToBackup.hashCode ^ backedUpAssets.hashCode;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'duplicated_asset.model.g.dart';
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class DuplicatedAsset {
|
||||
String id;
|
||||
DuplicatedAsset(this.id);
|
||||
Id get isarId => fastHash(id);
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'duplicated_asset.model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
|
||||
|
||||
extension GetDuplicatedAssetCollection on Isar {
|
||||
IsarCollection<DuplicatedAsset> get duplicatedAssets => this.collection();
|
||||
}
|
||||
|
||||
const DuplicatedAssetSchema = CollectionSchema(
|
||||
name: r'DuplicatedAsset',
|
||||
id: -2679334728174694496,
|
||||
properties: {
|
||||
r'id': PropertySchema(
|
||||
id: 0,
|
||||
name: r'id',
|
||||
type: IsarType.string,
|
||||
)
|
||||
},
|
||||
estimateSize: _duplicatedAssetEstimateSize,
|
||||
serialize: _duplicatedAssetSerialize,
|
||||
deserialize: _duplicatedAssetDeserialize,
|
||||
deserializeProp: _duplicatedAssetDeserializeProp,
|
||||
idName: r'isarId',
|
||||
indexes: {},
|
||||
links: {},
|
||||
embeddedSchemas: {},
|
||||
getId: _duplicatedAssetGetId,
|
||||
getLinks: _duplicatedAssetGetLinks,
|
||||
attach: _duplicatedAssetAttach,
|
||||
version: '3.1.0+1',
|
||||
);
|
||||
|
||||
int _duplicatedAssetEstimateSize(
|
||||
DuplicatedAsset object,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
bytesCount += 3 + object.id.length * 3;
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
void _duplicatedAssetSerialize(
|
||||
DuplicatedAsset object,
|
||||
IsarWriter writer,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeString(offsets[0], object.id);
|
||||
}
|
||||
|
||||
DuplicatedAsset _duplicatedAssetDeserialize(
|
||||
Id id,
|
||||
IsarReader reader,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = DuplicatedAsset(
|
||||
reader.readString(offsets[0]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
|
||||
P _duplicatedAssetDeserializeProp<P>(
|
||||
IsarReader reader,
|
||||
int propertyId,
|
||||
int offset,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readString(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
Id _duplicatedAssetGetId(DuplicatedAsset object) {
|
||||
return object.isarId;
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _duplicatedAssetGetLinks(DuplicatedAsset object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
void _duplicatedAssetAttach(
|
||||
IsarCollection<dynamic> col, Id id, DuplicatedAsset object) {}
|
||||
|
||||
extension DuplicatedAssetQueryWhereSort
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhere> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhere> anyIsarId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryWhere
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhereClause> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdEqualTo(Id isarId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(
|
||||
lower: isarId,
|
||||
upper: isarId,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdNotEqualTo(Id isarId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdGreaterThan(Id isarId, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdLessThan(Id isarId, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdBetween(
|
||||
Id lowerIsarId,
|
||||
Id upperIsarId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(
|
||||
lower: lowerIsarId,
|
||||
includeLower: includeLower,
|
||||
upper: upperIsarId,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryFilter
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idEqualTo(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idGreaterThan(
|
||||
String value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idLessThan(
|
||||
String value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idBetween(
|
||||
String lower,
|
||||
String upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'id',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idMatches(String pattern, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'id',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'id',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'id',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdEqualTo(Id value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdGreaterThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdLessThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdBetween(
|
||||
Id lower,
|
||||
Id upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'isarId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryObject
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
|
||||
|
||||
extension DuplicatedAssetQueryLinks
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
|
||||
|
||||
extension DuplicatedAssetQuerySortBy
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortBy> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQuerySortThenBy
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortThenBy> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIsarId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isarId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy>
|
||||
thenByIsarIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isarId', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryWhereDistinct
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> distinctById(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryProperty
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QQueryProperty> {
|
||||
QueryBuilder<DuplicatedAsset, int, QQueryOperations> isarIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'isarId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, String, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class BulkUploadCheckResponse {
|
||||
final List<String> toBeUploaded;
|
||||
final List<String> duplicates;
|
||||
|
||||
const BulkUploadCheckResponse({
|
||||
required this.toBeUploaded,
|
||||
required this.duplicates,
|
||||
});
|
||||
|
||||
BulkUploadCheckResponse copyWith({
|
||||
List<String>? toBeUploaded,
|
||||
List<String>? duplicates,
|
||||
}) {
|
||||
return BulkUploadCheckResponse(
|
||||
toBeUploaded: toBeUploaded ?? this.toBeUploaded,
|
||||
duplicates: duplicates ?? this.duplicates,
|
||||
);
|
||||
}
|
||||
|
||||
static BulkUploadCheckResponse fromDto(AssetBulkUploadCheckResponseDto dto) {
|
||||
final duplicates = <String>[];
|
||||
final toBeUploaded = <String>[];
|
||||
for (final result in dto.results) {
|
||||
if (result.action == AssetBulkUploadCheckResultActionEnum.accept) {
|
||||
toBeUploaded.add(result.id);
|
||||
} else if (result.action == AssetBulkUploadCheckResultActionEnum.reject &&
|
||||
result.reason == AssetBulkUploadCheckResultReasonEnum.duplicate) {
|
||||
duplicates.add(result.id);
|
||||
}
|
||||
}
|
||||
return BulkUploadCheckResponse(
|
||||
toBeUploaded: toBeUploaded.toList(),
|
||||
duplicates: duplicates.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BulkUploadCheckResponse(toBeUploaded: $toBeUploaded, duplicates: $duplicates)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BulkUploadCheckResponse other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.toBeUploaded, toBeUploaded) &&
|
||||
listEquals(other.duplicates, duplicates);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => toBeUploaded.hashCode ^ duplicates.hashCode;
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/device_asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -29,7 +29,7 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
BackupNotifier(
|
||||
this._backupService,
|
||||
this._serverInfoService,
|
||||
this._serverInfoNotifier,
|
||||
this._authState,
|
||||
this._backgroundService,
|
||||
this._galleryPermissionNotifier,
|
||||
@@ -38,26 +38,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
) : super(
|
||||
BackUpState(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetsInDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
cancelToken: CancellationToken(),
|
||||
autoBackup: Store.get(StoreKey.autoBackup, false),
|
||||
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
|
||||
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
|
||||
backupRequireCharging:
|
||||
Store.get(StoreKey.backupRequireCharging, false),
|
||||
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
|
||||
serverInfo: const ServerDiskInfo(
|
||||
diskAvailable: "0",
|
||||
diskSize: "0",
|
||||
diskUse: "0",
|
||||
diskUsagePercentage: 0,
|
||||
),
|
||||
availableAlbums: const [],
|
||||
selectedBackupAlbums: const {},
|
||||
excludedBackupAlbums: const {},
|
||||
allUniqueAssets: const {},
|
||||
selectedAlbumsBackupAssetsIds: const {},
|
||||
currentUploadAsset: CurrentUploadAsset(
|
||||
id: '...',
|
||||
fileCreatedAt: DateTime.parse('2020-10-04'),
|
||||
@@ -71,412 +53,76 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
final log = Logger('BackupNotifier');
|
||||
final BackupService _backupService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final ServerInfoNotifier _serverInfoNotifier;
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
||||
final Isar _db;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
/// UI INTERACTION
|
||||
///
|
||||
/// Album selection
|
||||
/// Due to the overlapping assets across multiple albums on the device
|
||||
/// We have method to include and exclude albums
|
||||
/// The total unique assets will be used for backing mechanism
|
||||
///
|
||||
void addAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.excludedBackupAlbums.contains(album)) {
|
||||
removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
|
||||
state = state
|
||||
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
|
||||
}
|
||||
|
||||
void addExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.selectedBackupAlbums.contains(album)) {
|
||||
removeAlbumForBackup(album);
|
||||
}
|
||||
state = state
|
||||
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
|
||||
}
|
||||
|
||||
void removeAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||
|
||||
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
|
||||
}
|
||||
|
||||
void removeExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||
|
||||
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
|
||||
}
|
||||
|
||||
Future<void> backupAlbumSelectionDone() {
|
||||
if (state.selectedBackupAlbums.isEmpty) {
|
||||
// disable any backup
|
||||
cancelBackup();
|
||||
setAutoBackup(false);
|
||||
configureBackgroundBackup(
|
||||
enabled: false,
|
||||
onError: (msg) {},
|
||||
onBatteryInfo: () {},
|
||||
);
|
||||
}
|
||||
return _updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void setAutoBackup(bool enabled) {
|
||||
Store.put(StoreKey.autoBackup, enabled);
|
||||
state = state.copyWith(autoBackup: enabled);
|
||||
}
|
||||
|
||||
void configureBackgroundBackup({
|
||||
bool? enabled,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
int? triggerDelay,
|
||||
required void Function(String msg) onError,
|
||||
required void Function() onBatteryInfo,
|
||||
}) async {
|
||||
assert(
|
||||
enabled != null ||
|
||||
requireWifi != null ||
|
||||
requireCharging != null ||
|
||||
triggerDelay != null,
|
||||
);
|
||||
final bool wasEnabled = state.backgroundBackup;
|
||||
final bool wasWifi = state.backupRequireWifi;
|
||||
final bool wasCharging = state.backupRequireCharging;
|
||||
final int oldTriggerDelay = state.backupTriggerDelay;
|
||||
state = state.copyWith(
|
||||
backgroundBackup: enabled,
|
||||
backupRequireWifi: requireWifi,
|
||||
backupRequireCharging: requireCharging,
|
||||
backupTriggerDelay: triggerDelay,
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
bool success = true;
|
||||
if (!wasEnabled) {
|
||||
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
||||
onBatteryInfo();
|
||||
}
|
||||
success &= await _backgroundService.enableService(immediate: true);
|
||||
}
|
||||
success &= success &&
|
||||
await _backgroundService.configureService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
triggerUpdateDelay: state.backupTriggerDelay,
|
||||
triggerMaxDelay: state.backupTriggerDelay * 10,
|
||||
);
|
||||
if (success) {
|
||||
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
|
||||
await Store.put(
|
||||
StoreKey.backupRequireCharging,
|
||||
state.backupRequireCharging,
|
||||
);
|
||||
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
|
||||
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
backgroundBackup: wasEnabled,
|
||||
backupRequireWifi: wasWifi,
|
||||
backupRequireCharging: wasCharging,
|
||||
backupTriggerDelay: oldTriggerDelay,
|
||||
);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await _backgroundService.disableService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Get all album on the device
|
||||
/// Get all selected and excluded album from the user's persistent storage
|
||||
/// If this is the first time performing backup - set the default selected album to be
|
||||
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
|
||||
///
|
||||
Future<void> _getBackupAlbumsInfo() async {
|
||||
Stopwatch stopwatch = Stopwatch()..start();
|
||||
// Get all albums on the device
|
||||
List<AvailableAlbum> availableAlbums = [];
|
||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
type: RequestType.common,
|
||||
);
|
||||
|
||||
// Map of id -> album for quick album lookup later on.
|
||||
Map<String, AssetPathEntity> albumMap = {};
|
||||
|
||||
log.info('Found ${albums.length} local albums');
|
||||
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
final assetCountInAlbum = await album.assetCountAsync;
|
||||
if (assetCountInAlbum > 0) {
|
||||
final assetList = await album.getAssetListPaged(page: 0, size: 1);
|
||||
|
||||
// Even though we check assetCountInAlbum to make sure that there are assets in album
|
||||
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
|
||||
if (assetList.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final thumbnailAsset = assetList.first;
|
||||
try {
|
||||
final thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum =
|
||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
} catch (e, stack) {
|
||||
log.severe(
|
||||
"Failed to get thumbnail for album ${album.name}",
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
|
||||
albumMap[album.id] = album;
|
||||
}
|
||||
}
|
||||
state = state.copyWith(availableAlbums: availableAlbums);
|
||||
|
||||
final List<BackupAlbum> excludedBackupAlbums =
|
||||
await _backupService.excludedAlbumsQuery().findAll();
|
||||
final List<BackupAlbum> selectedBackupAlbums =
|
||||
await _backupService.selectedAlbumsQuery().findAll();
|
||||
|
||||
// Generate AssetPathEntity from id to add to local state
|
||||
final Set<AvailableAlbum> selectedAlbums = {};
|
||||
for (final BackupAlbum ba in selectedBackupAlbums) {
|
||||
final albumAsset = albumMap[ba.id];
|
||||
|
||||
if (albumAsset != null) {
|
||||
selectedAlbums.add(
|
||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
||||
);
|
||||
} else {
|
||||
log.severe('Selected album not found');
|
||||
}
|
||||
}
|
||||
|
||||
final Set<AvailableAlbum> excludedAlbums = {};
|
||||
for (final BackupAlbum ba in excludedBackupAlbums) {
|
||||
final albumAsset = albumMap[ba.id];
|
||||
|
||||
if (albumAsset != null) {
|
||||
excludedAlbums.add(
|
||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
||||
);
|
||||
} else {
|
||||
log.severe('Excluded album not found');
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
|
||||
log.info(
|
||||
"_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums",
|
||||
);
|
||||
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
///
|
||||
/// From all the selected and albums assets
|
||||
/// Find the assets that are not overlapping between the two sets
|
||||
/// Those assets are unique and are used as the total assets
|
||||
///
|
||||
Future<void> _updateBackupAssetCount() async {
|
||||
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
|
||||
final Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||
final Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (final album in state.selectedBackupAlbums) {
|
||||
final assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (final album in state.excludedBackupAlbums) {
|
||||
final assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
final Set<AssetEntity> allUniqueAssets =
|
||||
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
|
||||
|
||||
if (allAssetsInDatabase == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find asset that were backup from selected albums
|
||||
final Set<String> selectedAlbumsBackupAssets =
|
||||
Set.from(allUniqueAssets.map((e) => e.id));
|
||||
|
||||
selectedAlbumsBackupAssets
|
||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||
|
||||
// Remove duplicated asset from all unique assets
|
||||
allUniqueAssets.removeWhere(
|
||||
(asset) => duplicatedAssetIds.contains(asset.id),
|
||||
);
|
||||
|
||||
if (allUniqueAssets.isEmpty) {
|
||||
log.info("No assets are selected for back up");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
allUniqueAssets: {},
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
allUniqueAssets: allUniqueAssets,
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||
);
|
||||
}
|
||||
|
||||
// Save to persistent storage
|
||||
await _updatePersistentAlbumsSelection();
|
||||
}
|
||||
|
||||
/// Get all necessary information for calculating the available albums,
|
||||
/// which albums are selected or excluded
|
||||
/// and then update the UI according to those information
|
||||
Future<void> getBackupInfo() async {
|
||||
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) {
|
||||
Store.put(StoreKey.backgroundBackup, isEnabled);
|
||||
}
|
||||
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await _getBackupAlbumsInfo();
|
||||
await updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
} else {
|
||||
log.warning("cannot get backup info - background backup is in progress!");
|
||||
}
|
||||
}
|
||||
|
||||
/// Save user selection of selected albums and excluded albums to database
|
||||
Future<void> _updatePersistentAlbumsSelection() {
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
final selected = state.selectedBackupAlbums.map(
|
||||
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
|
||||
);
|
||||
final excluded = state.excludedBackupAlbums.map(
|
||||
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
|
||||
);
|
||||
final backupAlbums = selected.followedBy(excluded).toList();
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
return _db.writeTxn(() async {
|
||||
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
b.lastBackup =
|
||||
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
|
||||
toUpsert.add(b);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
|
||||
onlySecond: (BackupAlbum b) => toUpsert.add(b),
|
||||
);
|
||||
await _db.backupAlbums.deleteAll(toDelete);
|
||||
await _db.backupAlbums.putAll(toUpsert);
|
||||
});
|
||||
}
|
||||
|
||||
/// Invoke backup process
|
||||
Future<void> startBackupProcess() async {
|
||||
debugPrint("Start backup process");
|
||||
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||
|
||||
await getBackupInfo();
|
||||
await _serverInfoNotifier.getServerDiskInfo();
|
||||
|
||||
final hasPermission = _galleryPermissionNotifier.hasPermission;
|
||||
if (hasPermission) {
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
if (state.allUniqueAssets.isEmpty) {
|
||||
log.info("No Asset On Device - Abort Backup Process");
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
||||
// Remove item that has already been backed up
|
||||
for (final assetId in state.allAssetsInDatabase) {
|
||||
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
||||
}
|
||||
|
||||
if (assetsWillBeBackup.isEmpty) {
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||
}
|
||||
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancellationToken());
|
||||
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
pmProgressHandler?.stream.listen((event) {
|
||||
final double progress = event.progress;
|
||||
state = state.copyWith(iCloudDownloadProgress: progress);
|
||||
});
|
||||
|
||||
await _backupService.backupAsset(
|
||||
assetsWillBeBackup,
|
||||
state.cancelToken,
|
||||
pmProgressHandler,
|
||||
_onAssetUploaded,
|
||||
_onUploadProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
await notifyBackgroundServiceCanRun();
|
||||
} else {
|
||||
if (!hasPermission) {
|
||||
openAppSettings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void setAvailableAlbums(availableAlbums) {
|
||||
state = state.copyWith(
|
||||
availableAlbums: availableAlbums,
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
final idsForBackup = await _db.deviceAssets
|
||||
.filter()
|
||||
.backupSelectionEqualTo(BackupSelection.select)
|
||||
.idProperty()
|
||||
.findAll();
|
||||
final localAssetsToBackup = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNull()
|
||||
.filter()
|
||||
.anyOf(idsForBackup, (q, id) => q.localIdEqualTo(id))
|
||||
.findAll();
|
||||
|
||||
final assetsToBackup =
|
||||
await _backupService.remoteAlreadyUploaded(localAssetsToBackup);
|
||||
if (assetsToBackup.isEmpty) {
|
||||
log.info("No Asset On Device - Abort Backup Process");
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancellationToken());
|
||||
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
pmProgressHandler?.stream.listen((event) {
|
||||
final double progress = event.progress;
|
||||
state = state.copyWith(iCloudDownloadProgress: progress);
|
||||
});
|
||||
|
||||
await _backupService.backupAsset(
|
||||
assetsToBackup,
|
||||
state.cancelToken,
|
||||
pmProgressHandler,
|
||||
_onAssetUploaded,
|
||||
_onUploadProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
|
||||
state.cancelToken.cancel();
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
progressInPercentage: 0.0,
|
||||
);
|
||||
ref.invalidate(deviceAssetsProvider);
|
||||
await notifyBackgroundServiceCanRun();
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
@@ -503,43 +149,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
String deviceId,
|
||||
bool isDuplicated,
|
||||
) {
|
||||
if (isDuplicated) {
|
||||
state = state.copyWith(
|
||||
allUniqueAssets: state.allUniqueAssets
|
||||
.where((asset) => asset.id != deviceAssetId)
|
||||
.toSet(),
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
selectedAlbumsBackupAssetsIds: {
|
||||
...state.selectedAlbumsBackupAssetsIds,
|
||||
deviceAssetId,
|
||||
},
|
||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
||||
);
|
||||
}
|
||||
|
||||
if (state.allUniqueAssets.length -
|
||||
state.selectedAlbumsBackupAssetsIds.length ==
|
||||
0) {
|
||||
final latestAssetBackup =
|
||||
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
|
||||
(v, e) => e.isAfter(v) ? e : v,
|
||||
);
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: state.selectedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
excludedBackupAlbums: state.excludedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
backupProgress: BackUpProgressEnum.done,
|
||||
progressInPercentage: 0.0,
|
||||
);
|
||||
_updatePersistentAlbumsSelection();
|
||||
}
|
||||
|
||||
updateServerInfo();
|
||||
_serverInfoNotifier.getServerDiskInfo();
|
||||
}
|
||||
|
||||
void _onUploadProgress(int sent, int total) {
|
||||
@@ -548,17 +158,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateServerInfo() async {
|
||||
final serverInfo = await _serverInfoService.getServerInfo();
|
||||
|
||||
// Update server info
|
||||
if (serverInfo != null) {
|
||||
state = state.copyWith(
|
||||
serverInfo: serverInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
// Check if user is login
|
||||
final accessKey = Store.tryGet(StoreKey.accessToken);
|
||||
@@ -570,7 +169,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if (state.autoBackup) {
|
||||
if (ref.read(backupSettingsProvider).autoBackup) {
|
||||
// check if backup is already in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
log.info("[_resumeBackup] Auto Backup is already in progress - abort");
|
||||
@@ -595,35 +194,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
Future<void> resumeBackup() async {
|
||||
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.select)
|
||||
.findAll();
|
||||
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.exclude)
|
||||
.findAll();
|
||||
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
|
||||
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
|
||||
if (selectedAlbums.isNotEmpty) {
|
||||
selectedAlbums = _updateAlbumsBackupTime(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
);
|
||||
}
|
||||
|
||||
if (excludedAlbums.isNotEmpty) {
|
||||
excludedAlbums = _updateAlbumsBackupTime(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
);
|
||||
}
|
||||
final BackUpProgressEnum previous = state.backupProgress;
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.inBackground,
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
||||
|
||||
// assumes the background service is currently running
|
||||
// if true, waits until it has stopped to start the backup
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
@@ -633,26 +206,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
return _resumeBackup();
|
||||
}
|
||||
|
||||
Set<AvailableAlbum> _updateAlbumsBackupTime(
|
||||
Set<AvailableAlbum> albums,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
) {
|
||||
Set<AvailableAlbum> result = {};
|
||||
for (BackupAlbum ba in backupAlbums) {
|
||||
try {
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
|
||||
result.add(a.copyWith(lastBackup: ba.lastBackup));
|
||||
} on StateError {
|
||||
log.severe(
|
||||
"[_updateAlbumBackupTime] failed to find album in state",
|
||||
"State Error",
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> notifyBackgroundServiceCanRun() async {
|
||||
const allowedStates = [
|
||||
AppStateEnum.inactive,
|
||||
@@ -674,7 +227,7 @@ final backupProvider =
|
||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(serverInfoServiceProvider),
|
||||
ref.watch(serverInfoProvider.notifier),
|
||||
ref.watch(authenticationProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref.watch(galleryPermissionNotifier.notifier),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup_album.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'backup_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class BackupAlbums extends _$BackupAlbums {
|
||||
@override
|
||||
Future<BackupAlbumState> build() async {
|
||||
final db = ref.read(dbProvider);
|
||||
return BackupAlbumState(
|
||||
selectedBackupAlbums: await db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.select)
|
||||
.findAll(),
|
||||
excludedBackupAlbums: await db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.exclude)
|
||||
.findAll(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reloadProviders(
|
||||
LocalAlbum album,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
final shouldReload = await ref
|
||||
.read(backupAlbumServiceProvider)
|
||||
.updateAlbumAssetsState(album, selection);
|
||||
if (shouldReload) {
|
||||
ref.invalidate(deviceAssetsProvider);
|
||||
}
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> addBackupAlbum(
|
||||
LocalAlbum album,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
await ref.read(backupAlbumServiceProvider).addBackupAlbum(album, selection);
|
||||
_reloadProviders(album, selection);
|
||||
}
|
||||
|
||||
Future<void> syncWithLocalAlbum(LocalAlbum album) async {
|
||||
await ref.read(backupAlbumServiceProvider).syncWithLocalAlbum(album);
|
||||
final albumInDB = await ref
|
||||
.read(dbProvider)
|
||||
.backupAlbums
|
||||
.filter()
|
||||
.idEqualTo(album.id)
|
||||
.findFirst();
|
||||
if (albumInDB?.selection != null) {
|
||||
_reloadProviders(album, albumInDB!.selection);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateAlbumSelection(
|
||||
LocalAlbum localAlbum,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
await ref
|
||||
.read(backupAlbumServiceProvider)
|
||||
.updateAlbumSelection(localAlbum, selection);
|
||||
_reloadProviders(localAlbum, selection);
|
||||
}
|
||||
|
||||
Future<void> refreshAlbumAssetsState() async =>
|
||||
ref.read(backupAlbumServiceProvider).refreshAlbumAssetsState();
|
||||
|
||||
Future<void> selectAlbumForBackup(LocalAlbum album) =>
|
||||
_updateAlbumSelection(album, BackupSelection.select);
|
||||
|
||||
Future<void> excludeAlbumFromBackup(LocalAlbum album) =>
|
||||
_updateAlbumSelection(album, BackupSelection.exclude);
|
||||
|
||||
Future<void> deSelectAlbum(LocalAlbum album) =>
|
||||
_updateAlbumSelection(album, BackupSelection.none);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'backup_album.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$backupAlbumsHash() => r'f37d088af2a837d61040fc7663bed61703196efd';
|
||||
|
||||
/// See also [BackupAlbums].
|
||||
@ProviderFor(BackupAlbums)
|
||||
final backupAlbumsProvider =
|
||||
AutoDisposeAsyncNotifierProvider<BackupAlbums, BackupAlbumState>.internal(
|
||||
BackupAlbums.new,
|
||||
name: r'backupAlbumsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$backupAlbumsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$BackupAlbums = AutoDisposeAsyncNotifier<BackupAlbumState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_setting.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'backup_settings.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class BackupSettings extends _$BackupSettings {
|
||||
@override
|
||||
BackupSetting build() {
|
||||
return BackupSetting(
|
||||
autoBackup: Store.get(StoreKey.autoBackup, false),
|
||||
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
|
||||
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
|
||||
backupRequireCharging: Store.get(StoreKey.backupRequireCharging, false),
|
||||
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
|
||||
);
|
||||
}
|
||||
|
||||
void setAutoBackup(bool enabled) {
|
||||
Store.put(StoreKey.autoBackup, enabled);
|
||||
state = state.copyWith(autoBackup: enabled);
|
||||
}
|
||||
|
||||
void configureBackgroundBackup({
|
||||
bool? enabled,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
int? triggerDelay,
|
||||
required void Function(String msg) onError,
|
||||
required void Function() onBatteryInfo,
|
||||
}) async {
|
||||
assert(
|
||||
enabled != null ||
|
||||
requireWifi != null ||
|
||||
requireCharging != null ||
|
||||
triggerDelay != null,
|
||||
);
|
||||
final backgroundService = ref.read(backgroundServiceProvider);
|
||||
|
||||
final bool wasEnabled = state.backgroundBackup;
|
||||
final bool wasWifi = state.backupRequireWifi;
|
||||
final bool wasCharging = state.backupRequireCharging;
|
||||
final int oldTriggerDelay = state.backupTriggerDelay;
|
||||
state = state.copyWith(
|
||||
backgroundBackup: enabled,
|
||||
backupRequireWifi: requireWifi,
|
||||
backupRequireCharging: requireCharging,
|
||||
backupTriggerDelay: triggerDelay,
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
bool success = true;
|
||||
if (!wasEnabled) {
|
||||
if (!await backgroundService.isIgnoringBatteryOptimizations()) {
|
||||
onBatteryInfo();
|
||||
}
|
||||
success &= await backgroundService.enableService(immediate: true);
|
||||
}
|
||||
success &= success &&
|
||||
await backgroundService.configureService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
triggerUpdateDelay: state.backupTriggerDelay,
|
||||
triggerMaxDelay: state.backupTriggerDelay * 10,
|
||||
);
|
||||
if (success) {
|
||||
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
|
||||
await Store.put(
|
||||
StoreKey.backupRequireCharging,
|
||||
state.backupRequireCharging,
|
||||
);
|
||||
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
|
||||
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
backgroundBackup: wasEnabled,
|
||||
backupRequireWifi: wasWifi,
|
||||
backupRequireCharging: wasCharging,
|
||||
backupTriggerDelay: oldTriggerDelay,
|
||||
);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await backgroundService.disableService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'backup_settings.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$backupSettingsHash() => r'839ad0c6c4b3bd61b9af75c38f8580a8af1d2e0a';
|
||||
|
||||
/// See also [BackupSettings].
|
||||
@ProviderFor(BackupSettings)
|
||||
final backupSettingsProvider =
|
||||
AutoDisposeNotifierProvider<BackupSettings, BackupSetting>.internal(
|
||||
BackupSettings.new,
|
||||
name: r'backupSettingsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$backupSettingsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$BackupSettings = AutoDisposeNotifier<BackupSetting>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/device_album_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/device_asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'device_assets.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class DeviceAssets extends _$DeviceAssets {
|
||||
@override
|
||||
Future<DeviceAssetState> build() async {
|
||||
final db = ref.read(dbProvider);
|
||||
final idsToBackup = await db.deviceAssets
|
||||
.filter()
|
||||
.backupSelectionEqualTo(BackupSelection.select)
|
||||
.idProperty()
|
||||
.findAll();
|
||||
|
||||
return DeviceAssetState(
|
||||
uniqueAssetsToBackup: await db.assets
|
||||
.filter()
|
||||
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
|
||||
.count(),
|
||||
backedUpAssets: await db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.anyOf(idsToBackup, (q, id) => q.localIdEqualTo(id))
|
||||
.count(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'device_assets.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$deviceAssetsHash() => r'0fd5eb2d0f02d125df780e5676a6dcee2234de2f';
|
||||
|
||||
/// See also [DeviceAssets].
|
||||
@ProviderFor(DeviceAssets)
|
||||
final deviceAssetsProvider =
|
||||
AutoDisposeAsyncNotifierProvider<DeviceAssets, DeviceAssetState>.internal(
|
||||
DeviceAssets.new,
|
||||
name: r'deviceAssetsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$deviceAssetsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$DeviceAssets = AutoDisposeAsyncNotifier<DeviceAssetState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/local_notification.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
@@ -115,7 +116,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
bool isDuplicated,
|
||||
) {
|
||||
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
|
||||
_backupProvider.updateServerInfo();
|
||||
ref.read(serverInfoProvider.notifier).getServerDiskInfo();
|
||||
}
|
||||
|
||||
void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) {
|
||||
@@ -158,25 +159,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
|
||||
// where platform specific fields such as `subtype` used to detect platform specific assets such as
|
||||
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
|
||||
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
|
||||
allManualUploads
|
||||
// Filter local only assets
|
||||
.where((e) => e.isLocal && !e.isRemote)
|
||||
.map((e) => e.local!.obtainForNewProperties()),
|
||||
);
|
||||
|
||||
if (allAssetsFromDevice.length != allManualUploads.length) {
|
||||
_log.warning(
|
||||
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
|
||||
);
|
||||
}
|
||||
|
||||
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
|
||||
|
||||
if (allUploadAssets.isEmpty) {
|
||||
if (allManualUploads.isEmpty) {
|
||||
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
return false;
|
||||
@@ -184,7 +167,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
|
||||
state = state.copyWith(
|
||||
progressInPercentage: 0,
|
||||
totalAssetsToUpload: allUploadAssets.length,
|
||||
totalAssetsToUpload: allManualUploads.length,
|
||||
successfulUploads: 0,
|
||||
currentAssetIndex: 0,
|
||||
currentUploadAsset: CurrentUploadAsset(
|
||||
@@ -213,7 +196,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
final bool ok = await ref.read(backupServiceProvider).backupAsset(
|
||||
allUploadAssets,
|
||||
allManualUploads,
|
||||
state.cancelToken,
|
||||
pmProgressHandler,
|
||||
_onAssetUploaded,
|
||||
|
||||
@@ -7,10 +7,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/upload_check_response.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart' hide AssetType;
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
@@ -40,26 +41,38 @@ class BackupService {
|
||||
|
||||
BackupService(this._apiService, this._db, this._appSetting);
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
Future<BulkUploadCheckResponse?> _bulkUploadCheck(List<Asset> assets) async {
|
||||
try {
|
||||
return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId);
|
||||
final dto = await _apiService.assetApi.checkBulkUpload(
|
||||
AssetBulkUploadCheckDto(
|
||||
assets: assets
|
||||
.where((a) => a.localId != null)
|
||||
.map(
|
||||
(e) => AssetBulkUploadCheckItem(
|
||||
checksum: e.checksum,
|
||||
id: e.localId!,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
|
||||
return dto != null ? BulkUploadCheckResponse.fromDto(dto) : null;
|
||||
} catch (e) {
|
||||
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
|
||||
debugPrint('Error [bulkUploadCheck] ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
|
||||
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
|
||||
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
|
||||
}
|
||||
Future<Iterable<Asset>> remoteAlreadyUploaded(List<Asset> assets) async {
|
||||
final uploadCheck = await _bulkUploadCheck(assets);
|
||||
if (uploadCheck == null) {
|
||||
_log.warning("Cannot determine duplicates. Skipping backup for now");
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get duplicated asset id from database
|
||||
Future<Set<String>> getDuplicatedAssetIds() async {
|
||||
final duplicates = await _db.duplicatedAssets.where().findAll();
|
||||
return duplicates.map((e) => e.id).toSet();
|
||||
return uploadCheck.toBeUploaded
|
||||
.map((c) => assets.firstWhere((a) => a.localId == c));
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
@@ -69,138 +82,8 @@ class BackupService {
|
||||
excludedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
List<BackupAlbum> selectedBackupAlbums,
|
||||
List<BackupAlbum> excludedBackupAlbums,
|
||||
) async {
|
||||
final filter = FilterOptionGroup(
|
||||
containsPathModified: true,
|
||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||
// title is needed to create Assets
|
||||
imageOption: const FilterOption(needTitle: true),
|
||||
videoOption: const FilterOption(needTitle: true),
|
||||
);
|
||||
final now = DateTime.now();
|
||||
final List<AssetPathEntity?> selectedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
|
||||
if (selectedAlbums.every((e) => e == null)) {
|
||||
return [];
|
||||
}
|
||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
||||
if (allIdx != -1) {
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
|
||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||
selectedBackupAlbums.slice(allIdx, allIdx + 1),
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||
} else {
|
||||
return await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||
List<BackupAlbum> albums,
|
||||
FilterOptionGroup filter,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetPathEntity?> result = [];
|
||||
for (BackupAlbum a in albums) {
|
||||
try {
|
||||
final AssetPathEntity album =
|
||||
await AssetPathEntity.obtainPathFromProperties(
|
||||
id: a.id,
|
||||
optionGroup: filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: a.lastBackup.subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
),
|
||||
maxDateTimeToNow: false,
|
||||
);
|
||||
result.add(album);
|
||||
} on StateError {
|
||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> albums,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetEntity> result = [];
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null &&
|
||||
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
backupAlbums[i].lastBackup = now;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns a new list of assets not yet uploaded
|
||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
) async {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
||||
.toList();
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final CheckExistingAssetsResponseDto? duplicates =
|
||||
await _apiService.assetApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
if (duplicates != null) {
|
||||
existing.addAll(duplicates.existingIds);
|
||||
}
|
||||
} on ApiException {
|
||||
// workaround for older server versions or when checking for too many assets at once
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
if (allAssetsInDatabase != null) {
|
||||
existing.addAll(allAssetsInDatabase);
|
||||
}
|
||||
}
|
||||
return existing.isEmpty
|
||||
? candidates
|
||||
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<AssetEntity> assetList,
|
||||
Iterable<Asset> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
PMProgressHandler? pmProgressHandler,
|
||||
Function(String, String, bool) uploadSuccessCb,
|
||||
@@ -223,26 +106,36 @@ class BackupService {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
bool anyErrors = false;
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
|
||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||
if (Platform.isIOS) {
|
||||
await PhotoManager.requestPermissionExtend();
|
||||
}
|
||||
|
||||
List<AssetEntity> assetsToUpload = sortAssets
|
||||
List<Asset> assetsToUpload = sortAssets
|
||||
// Upload images before video assets
|
||||
// these are further sorted by using their creation date
|
||||
? assetList.sorted(
|
||||
(a, b) {
|
||||
final cmp = a.typeInt - b.typeInt;
|
||||
final cmp = a.type.index - b.type.index;
|
||||
if (cmp != 0) return cmp;
|
||||
return a.createDateTime.compareTo(b.createDateTime);
|
||||
return a.fileCreatedAt.compareTo(b.fileCreatedAt);
|
||||
},
|
||||
)
|
||||
: assetList.toList();
|
||||
|
||||
for (var entity in assetsToUpload) {
|
||||
for (final asset in assetsToUpload) {
|
||||
if (!asset.isLocal) {
|
||||
_log.severe("Asset - ${asset.id} is not a local asset");
|
||||
continue;
|
||||
}
|
||||
|
||||
final entity = (await AssetEntity.fromId(asset.localId!));
|
||||
if (entity == null) {
|
||||
_log.severe("Cannot fetch asset entity for - ${asset.localId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
@@ -353,8 +246,6 @@ class BackupService {
|
||||
await httpClient.send(req, cancellationToken: cancelToken);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// asset is a duplicate (already exists on the server)
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
uploadSuccessCb(entity.id, deviceId, true);
|
||||
} else if (response.statusCode == 201) {
|
||||
// stored a new asset on the server
|
||||
@@ -405,9 +296,6 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/album_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/device_asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final backupAlbumServiceProvider = Provider(
|
||||
(ref) => BackupAlbumService(
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class BackupAlbumService {
|
||||
final Isar _db;
|
||||
final Logger _log = Logger("BackupAlbumService");
|
||||
|
||||
BackupAlbumService(this._db);
|
||||
|
||||
Future<void> addBackupAlbum(
|
||||
LocalAlbum album,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
final albumInDB =
|
||||
await _db.backupAlbums.filter().idEqualTo(album.id).findFirst();
|
||||
|
||||
final backupAlbum = albumInDB ??
|
||||
BackupAlbum(
|
||||
id: album.id,
|
||||
selection: selection,
|
||||
);
|
||||
|
||||
backupAlbum.selection = selection;
|
||||
backupAlbum.album.value = album;
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.backupAlbums.store(backupAlbum);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWithLocalAlbum(LocalAlbum album) async {
|
||||
final albumInDB =
|
||||
await _db.backupAlbums.filter().idEqualTo(album.id).findFirst();
|
||||
if (albumInDB == null) {
|
||||
_log.fine("No backup album for local album - ${album.name}");
|
||||
return;
|
||||
}
|
||||
albumInDB.album.value = album;
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.backupAlbums.store(albumInDB);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> refreshAlbumAssetsState() async {
|
||||
final backupAlbums = await _db.backupAlbums.where().findAll();
|
||||
for (final album in backupAlbums) {
|
||||
final localAlbum = album.album.value;
|
||||
if (localAlbum != null) {
|
||||
await updateAlbumAssetsState(localAlbum, album.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAlbumSelection(
|
||||
LocalAlbum localAlbum,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
await localAlbum.backup.load();
|
||||
final backupAlbum = localAlbum.backup.value;
|
||||
|
||||
if (backupAlbum == null) {
|
||||
return addBackupAlbum(localAlbum, selection);
|
||||
}
|
||||
backupAlbum.selection = selection;
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.backupAlbums.store(backupAlbum);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<LocalAlbum>> _getAllLocalAlbumWithAsset(Asset asset) async {
|
||||
return await _db.localAlbums
|
||||
.filter()
|
||||
.assets((q) => q.idEqualTo(asset.id))
|
||||
.findAll();
|
||||
}
|
||||
|
||||
Future<bool> updateAlbumAssetsState(
|
||||
LocalAlbum album,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
final assets = await _updateDeviceAssetsToSelection(album, selection);
|
||||
|
||||
if (assets.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.deviceAssets.putAll(assets);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<List<DeviceAsset>> _updateDeviceAssetsToSelection(
|
||||
LocalAlbum album,
|
||||
BackupSelection selection,
|
||||
) async {
|
||||
await album.assets.load();
|
||||
final assets = album.assets.toList();
|
||||
final updatedAssets = <DeviceAsset>[];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (!asset.isLocal) {
|
||||
_log.warning("Local id not available for asset ID - ${asset.id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
final deviceAsset =
|
||||
await _db.deviceAssets.where().idEqualTo(asset.localId!).findFirst();
|
||||
if (deviceAsset == null) {
|
||||
_log.warning(
|
||||
"Device asset not available for local asset ID - ${asset.id}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (deviceAsset.backupSelection == selection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exclude takes priority
|
||||
if (selection == BackupSelection.exclude) {
|
||||
deviceAsset.backupSelection = selection;
|
||||
} else if (selection == BackupSelection.select) {
|
||||
bool shouldExclude = false;
|
||||
final localAlbums = await _getAllLocalAlbumWithAsset(asset);
|
||||
for (final a in localAlbums) {
|
||||
await a.backup.load();
|
||||
// Check if there is any other excluded albums in which the asset is present
|
||||
if (a.backup.value?.selection == BackupSelection.exclude &&
|
||||
a.id != album.id) {
|
||||
shouldExclude = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Force exclude ignoring selection if asset is part of another excluded album
|
||||
deviceAsset.backupSelection =
|
||||
shouldExclude ? BackupSelection.exclude : BackupSelection.select;
|
||||
} else if (selection == BackupSelection.none) {
|
||||
bool setToNone = true;
|
||||
BackupSelection? oldSelection;
|
||||
final localAlbums = await _getAllLocalAlbumWithAsset(asset);
|
||||
for (final a in localAlbums) {
|
||||
await a.backup.load();
|
||||
// Check if there is any other albums in which the asset is present
|
||||
if (a.backup.value?.selection != BackupSelection.none &&
|
||||
a.id != album.id) {
|
||||
setToNone = false;
|
||||
oldSelection = a.backup.value?.selection;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only set to none when the asset is not part of any other selected or excluded albums
|
||||
if (setToNone) {
|
||||
deviceAsset.backupSelection = BackupSelection.none;
|
||||
} else if (oldSelection != null) {
|
||||
deviceAsset.backupSelection = oldSelection;
|
||||
}
|
||||
}
|
||||
|
||||
updatedAssets.add(deviceAsset);
|
||||
}
|
||||
|
||||
return updatedAssets;
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class AlbumInfoCard extends HookConsumerWidget {
|
||||
final Uint8List? imageData;
|
||||
final AvailableAlbum albumInfo;
|
||||
|
||||
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isSelected =
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
final bool isExcluded =
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
|
||||
ColorFilter selectedFilter = ColorFilter.mode(
|
||||
context.primaryColor.withAlpha(100),
|
||||
BlendMode.darken,
|
||||
);
|
||||
ColorFilter excludedFilter =
|
||||
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
||||
ColorFilter unselectedFilter =
|
||||
const ColorFilter.mode(Colors.black, BlendMode.color);
|
||||
|
||||
buildSelectedTextBox() {
|
||||
if (isSelected) {
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
label: Text(
|
||||
"album_info_card_backup_album_included",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
backgroundColor: context.primaryColor,
|
||||
);
|
||||
} else if (isExcluded) {
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
label: Text(
|
||||
"album_info_card_backup_album_excluded",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
backgroundColor: Colors.red[300],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
buildImageFilter() {
|
||||
if (isSelected) {
|
||||
return selectedFilter;
|
||||
} else if (isExcluded) {
|
||||
return excludedFilter;
|
||||
} else {
|
||||
return unselectedFilter;
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isSelected) {
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
||||
} else {
|
||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
||||
}
|
||||
},
|
||||
onDoubleTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isExcluded) {
|
||||
// Remove from exclude album list
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(albumInfo);
|
||||
} else {
|
||||
// Add to exclude album list
|
||||
|
||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Cannot exclude album contains all assets',
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.addExcludedAlbumForBackup(albumInfo);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
margin: const EdgeInsets.all(1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12), // if you need this
|
||||
side: BorderSide(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 37, 35, 35)
|
||||
: const Color(0xFFC9C9C9),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
borderOnForeground: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
ColorFiltered(
|
||||
colorFilter: buildImageFilter(),
|
||||
child: Image(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
image: imageData != null
|
||||
? MemoryImage(imageData!)
|
||||
: const AssetImage(
|
||||
'assets/immich-logo-no-outline.png',
|
||||
) as ImageProvider,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
right: 25,
|
||||
child: buildSelectedTextBox(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 25,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
albumInfo.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: FutureBuilder(
|
||||
builder: ((context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
snapshot.data.toString() +
|
||||
(albumInfo.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text("0");
|
||||
}),
|
||||
future: albumInfo.assetCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.pushRoute(
|
||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 25,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class AlbumInfoListTile extends HookConsumerWidget {
|
||||
final Uint8List? imageData;
|
||||
final AvailableAlbum albumInfo;
|
||||
|
||||
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isSelected =
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
final bool isExcluded =
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
|
||||
ColorFilter selectedFilter = ColorFilter.mode(
|
||||
context.primaryColor.withAlpha(100),
|
||||
BlendMode.darken,
|
||||
);
|
||||
ColorFilter excludedFilter =
|
||||
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
||||
ColorFilter unselectedFilter =
|
||||
const ColorFilter.mode(Colors.black, BlendMode.color);
|
||||
|
||||
var assetCount = useState(0);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
albumInfo.assetCount.then((value) => assetCount.value = value);
|
||||
return null;
|
||||
},
|
||||
[albumInfo],
|
||||
);
|
||||
|
||||
buildImageFilter() {
|
||||
if (isSelected) {
|
||||
return selectedFilter;
|
||||
} else if (isExcluded) {
|
||||
return excludedFilter;
|
||||
} else {
|
||||
return unselectedFilter;
|
||||
}
|
||||
}
|
||||
|
||||
buildTileColor() {
|
||||
if (isSelected) {
|
||||
return context.isDarkTheme
|
||||
? context.primaryColor.withAlpha(100)
|
||||
: context.primaryColor.withAlpha(25);
|
||||
} else if (isExcluded) {
|
||||
return context.isDarkTheme
|
||||
? Colors.red[300]?.withAlpha(150)
|
||||
: Colors.red[100]?.withAlpha(150);
|
||||
} else {
|
||||
return Colors.transparent;
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onDoubleTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isExcluded) {
|
||||
// Remove from exclude album list
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(albumInfo);
|
||||
} else {
|
||||
// Add to exclude album list
|
||||
|
||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Cannot exclude album contains all assets',
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(backupProvider.notifier)
|
||||
.addExcludedAlbumForBackup(albumInfo);
|
||||
}
|
||||
},
|
||||
child: ListTile(
|
||||
tileColor: buildTileColor(),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
if (isSelected) {
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
||||
} else {
|
||||
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
||||
}
|
||||
},
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
width: 80,
|
||||
child: ColorFiltered(
|
||||
colorFilter: buildImageFilter(),
|
||||
child: Image(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
image: imageData != null
|
||||
? MemoryImage(imageData!)
|
||||
: const AssetImage(
|
||||
'assets/immich-logo-no-outline.png',
|
||||
) as ImageProvider,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
albumInfo.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(assetCount.value.toString()),
|
||||
trailing: IconButton(
|
||||
onPressed: () {
|
||||
context.pushRoute(
|
||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 25,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class BackupAlbumInfoListItem extends ConsumerWidget {
|
||||
final LocalAlbum album;
|
||||
|
||||
const BackupAlbumInfoListItem({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final backupAlbums = ref.watch(backupAlbumsProvider);
|
||||
final backupAlbumNotifier = ref.read(backupAlbumsProvider.notifier);
|
||||
final isSelected =
|
||||
backupAlbums.value?.selectedBackupAlbums.any((a) => a.id == album.id) ??
|
||||
false;
|
||||
final isExcluded =
|
||||
backupAlbums.value?.excludedBackupAlbums.any((a) => a.id == album.id) ??
|
||||
false;
|
||||
final backupSelection = isSelected
|
||||
? BackupSelection.select
|
||||
: isExcluded
|
||||
? BackupSelection.exclude
|
||||
: BackupSelection.none;
|
||||
|
||||
void onTap() {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isSelected || isExcluded) {
|
||||
backupAlbumNotifier.deSelectAlbum(album);
|
||||
} else {
|
||||
backupAlbumNotifier.selectAlbumForBackup(album);
|
||||
}
|
||||
}
|
||||
|
||||
void onDoubleTap() {
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (isExcluded) {
|
||||
backupAlbumNotifier.deSelectAlbum(album);
|
||||
} else {
|
||||
if (album.id == LocalAlbum.isAllId || album.name == 'Recents') {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Cannot exclude album contains all assets',
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
backupAlbumNotifier.excludeAlbumFromBackup(album);
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onDoubleTap: onDoubleTap,
|
||||
child: context.isMobile
|
||||
? _AlbumDetailListTile(album, backupSelection)
|
||||
: _AlbumDetailCard(album, backupSelection),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumFilteredThumbnail extends StatelessWidget {
|
||||
final Asset? thumbnail;
|
||||
final BackupSelection selection;
|
||||
|
||||
const _AlbumFilteredThumbnail(this.thumbnail, this.selection);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ColorFilter selectedFilter = ColorFilter.mode(
|
||||
context.primaryColor.withAlpha(100),
|
||||
BlendMode.darken,
|
||||
);
|
||||
|
||||
ColorFilter excludedFilter =
|
||||
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
||||
|
||||
ColorFilter unselectedFilter =
|
||||
const ColorFilter.mode(Colors.black, BlendMode.color);
|
||||
|
||||
return ColorFiltered(
|
||||
colorFilter: switch (selection) {
|
||||
BackupSelection.select => selectedFilter,
|
||||
BackupSelection.exclude => excludedFilter,
|
||||
BackupSelection.none => unselectedFilter,
|
||||
},
|
||||
child: ImmichImage(thumbnail),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Portrait list components
|
||||
|
||||
class _AlbumDetailListTile extends StatelessWidget {
|
||||
final LocalAlbum album;
|
||||
final BackupSelection selection;
|
||||
|
||||
const _AlbumDetailListTile(this.album, this.selection);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Wrapped ListTile with Material to prevent tileColor overflow
|
||||
// https://github.com/flutter/flutter/issues/86584
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
tileColor: switch (selection) {
|
||||
BackupSelection.select => context.isDarkTheme
|
||||
? context.primaryColor.withAlpha(100)
|
||||
: context.primaryColor.withAlpha(25),
|
||||
BackupSelection.exclude => context.isDarkTheme
|
||||
? Colors.red[300]?.withAlpha(150)
|
||||
: Colors.red[100]?.withAlpha(150),
|
||||
BackupSelection.none => Colors.transparent,
|
||||
},
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
width: 80,
|
||||
child: _AlbumFilteredThumbnail(album.thumbnail, selection),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(album.assetCount.toString()),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_AlbumDetailCardChip(selection),
|
||||
_AlbumPreviewButton(album),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Landscape card components
|
||||
|
||||
class _AlbumDetailCard extends StatelessWidget {
|
||||
final LocalAlbum album;
|
||||
final BackupSelection selection;
|
||||
|
||||
const _AlbumDetailCard(this.album, this.selection);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
margin: const EdgeInsets.all(1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(12)), // if you need this
|
||||
side: BorderSide(
|
||||
color: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 37, 35, 35)
|
||||
: const Color(0xFFC9C9C9),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
borderOnForeground: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
_AlbumFilteredThumbnail(
|
||||
album.thumbnail,
|
||||
selection,
|
||||
),
|
||||
if (selection != BackupSelection.none)
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
right: 25,
|
||||
child: _AlbumDetailCardChip(selection),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 25),
|
||||
child: _AlbumDetailCardDetails(album),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumDetailCardChip extends StatelessWidget {
|
||||
final BackupSelection selection;
|
||||
|
||||
const _AlbumDetailCardChip(this.selection);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selection == BackupSelection.none) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
label: Text(
|
||||
selection == BackupSelection.select
|
||||
? "album_info_card_backup_album_included"
|
||||
: "album_info_card_backup_album_excluded",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: context.isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
backgroundColor: selection == BackupSelection.select
|
||||
? context.primaryColor
|
||||
: Colors.red[300],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumDetailCardDetails extends StatelessWidget {
|
||||
final LocalAlbum album;
|
||||
|
||||
const _AlbumDetailCardDetails(this.album);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
album.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: FutureBuilder(
|
||||
builder: ((context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
album.assetCount.toString() +
|
||||
(snapshot.data!.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text("0");
|
||||
}),
|
||||
future: AssetPathEntity.fromId(album.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_AlbumPreviewButton(album),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumPreviewButton extends StatelessWidget {
|
||||
final LocalAlbum album;
|
||||
|
||||
const _AlbumPreviewButton(this.album);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: () => context.pushRoute(
|
||||
LocalAlbumViewerRoute(
|
||||
album: album,
|
||||
selectEnabled: false,
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 25,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AlbumPreviewPage extends HookConsumerWidget {
|
||||
final AssetPathEntity album;
|
||||
const AlbumPreviewPage({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assets = useState<List<AssetEntity>>([]);
|
||||
|
||||
getAssetsInAlbum() async {
|
||||
assets.value = await album.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.assetCountAsync,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
getAssetsInAlbum();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
title: Column(
|
||||
children: [
|
||||
Text(
|
||||
album.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
"ID ${album.id}",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: IconButton(
|
||||
onPressed: () => context.popRoute(),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
),
|
||||
body: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
crossAxisSpacing: 2,
|
||||
mainAxisSpacing: 2,
|
||||
),
|
||||
itemCount: assets.value.length,
|
||||
itemBuilder: (context, index) {
|
||||
Future<Uint8List?> thumbData =
|
||||
assets.value[index].thumbnailDataWithSize(
|
||||
const ThumbnailSize(200, 200),
|
||||
quality: 50,
|
||||
);
|
||||
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: thumbData,
|
||||
builder: ((context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data != null) {
|
||||
return Image.memory(
|
||||
snapshot.data!,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox(
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: ImmichLoadingIndicator(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/album_info_list_tile.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/backup_album_info_list_item.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -15,194 +15,19 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
const BackupAlbumSelectionPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
|
||||
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
final albums = ref.watch(backupProvider).availableAlbums;
|
||||
final localAlbums = ref.watch(localAlbumsProvider);
|
||||
final searchValue = useValueNotifier('');
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
ref.watch(backupProvider.notifier).getBackupInfo();
|
||||
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
buildAlbumSelectionList() {
|
||||
if (albums.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
((context, index) {
|
||||
var thumbnailData = albums[index].thumbnailData;
|
||||
return AlbumInfoListTile(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: albums[index],
|
||||
);
|
||||
}),
|
||||
childCount: albums.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumSelectionGrid() {
|
||||
if (albums.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 300,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: ((context, index) {
|
||||
var thumbnailData = albums[index].thumbnailData;
|
||||
return AlbumInfoCard(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: albums[index],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildSelectedAlbumNameChip() {
|
||||
return selectedBackupAlbums.map((album) {
|
||||
void removeSelection() =>
|
||||
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: GestureDetector(
|
||||
onTap: removeSelection,
|
||||
child: Chip(
|
||||
label: Text(
|
||||
album.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: context.primaryColor,
|
||||
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
deleteIcon: const Icon(
|
||||
Icons.cancel_rounded,
|
||||
size: 15,
|
||||
),
|
||||
onDeleted: removeSelection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toSet();
|
||||
}
|
||||
|
||||
buildExcludedAlbumNameChip() {
|
||||
return excludedBackupAlbums.map((album) {
|
||||
void removeSelection() {
|
||||
ref
|
||||
.watch(backupProvider.notifier)
|
||||
.removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: removeSelection,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Chip(
|
||||
label: Text(
|
||||
album.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDarkTheme ? Colors.black : immichBackgroundColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.red[300],
|
||||
deleteIconColor:
|
||||
isDarkTheme ? Colors.black : immichBackgroundColor,
|
||||
deleteIcon: const Icon(
|
||||
Icons.cancel_rounded,
|
||||
size: 15,
|
||||
),
|
||||
onDeleted: removeSelection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toSet();
|
||||
}
|
||||
|
||||
// buildSearchBar() {
|
||||
// return Padding(
|
||||
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
|
||||
// child: TextFormField(
|
||||
// onChanged: (searchValue) {
|
||||
// // if (searchValue.isEmpty) {
|
||||
// // albums = availableAlbums;
|
||||
// // } else {
|
||||
// // albums.value = availableAlbums
|
||||
// // .where(
|
||||
// // (album) => album.name
|
||||
// // .toLowerCase()
|
||||
// // .contains(searchValue.toLowerCase()),
|
||||
// // )
|
||||
// // .toList();
|
||||
// // }
|
||||
// },
|
||||
// decoration: InputDecoration(
|
||||
// contentPadding: const EdgeInsets.symmetric(
|
||||
// horizontal: 8.0,
|
||||
// vertical: 8.0,
|
||||
// ),
|
||||
// hintText: "Search",
|
||||
// hintStyle: TextStyle(
|
||||
// color: isDarkTheme ? Colors.white : Colors.grey,
|
||||
// fontSize: 14.0,
|
||||
// ),
|
||||
// prefixIcon: const Icon(
|
||||
// Icons.search,
|
||||
// color: Colors.grey,
|
||||
// ),
|
||||
// border: OutlineInputBorder(
|
||||
// borderRadius: BorderRadius.circular(10),
|
||||
// borderSide: BorderSide.none,
|
||||
// ),
|
||||
// filled: true,
|
||||
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => context.popRoute(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
title: const Text(
|
||||
"backup_album_selection_page_select_albums",
|
||||
).tr(),
|
||||
elevation: 0,
|
||||
),
|
||||
appBar: _AppBar(),
|
||||
body: CustomScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
slivers: [
|
||||
@@ -210,105 +35,26 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
child: Text(
|
||||
"backup_album_selection_page_selection_info",
|
||||
style: context.textTheme.titleSmall,
|
||||
).tr(),
|
||||
),
|
||||
// Selected Album Chips
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Wrap(
|
||||
children: [
|
||||
...buildSelectedAlbumNameChip(),
|
||||
...buildExcludedAlbumNameChip(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: Text(
|
||||
"backup_album_selection_page_albums_device".tr(
|
||||
args: [
|
||||
ref
|
||||
.watch(backupProvider)
|
||||
.availableAlbums
|
||||
.length
|
||||
.toString(),
|
||||
],
|
||||
),
|
||||
style: context.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
"backup_album_selection_page_albums_tap",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
trailing: IconButton(
|
||||
splashRadius: 16,
|
||||
icon: Icon(
|
||||
Icons.info,
|
||||
size: 20,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
onPressed: () {
|
||||
// show the dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'backup_album_selection_page_selection_info',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text(
|
||||
'backup_album_selection_page_assets_scatter',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// buildSearchBar(),
|
||||
_AlbumBackupInfoRow(localAlbums.valueOrNull?.length ?? 0),
|
||||
_AlbumSearchBar(onSearch: (value) => searchValue.value = value),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.crossAxisExtent > 600) {
|
||||
return buildAlbumSelectionGrid();
|
||||
} else {
|
||||
return buildAlbumSelectionList();
|
||||
}
|
||||
ValueListenableBuilder(
|
||||
valueListenable: searchValue,
|
||||
builder: (ctx, search, _) {
|
||||
final filteredAlbums = searchValue.value.isEmpty
|
||||
? localAlbums
|
||||
: localAlbums.whenData(
|
||||
(albums) => albums
|
||||
.where(
|
||||
(a) => a.name
|
||||
.toLowerCase()
|
||||
.contains(searchValue.value.toLowerCase()),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
return _SilverLocalAlbumSelectionList(albums: filteredAlbums);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -316,3 +62,172 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends ImmichAppBar {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => context.popRoute(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
title: const Text(
|
||||
"backup_album_selection_page_select_albums",
|
||||
).tr(),
|
||||
elevation: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumBackupInfoRow extends StatelessWidget {
|
||||
final int albumCount;
|
||||
|
||||
const _AlbumBackupInfoRow(this.albumCount);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void showBackupSelectionInfoDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'backup_album_selection_page_selection_info',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text(
|
||||
'backup_album_selection_page_assets_scatter',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
"backup_album_selection_page_albums_device"
|
||||
.tr(args: [albumCount.toString()]),
|
||||
style: context.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
"backup_album_selection_page_albums_tap",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
trailing: IconButton(
|
||||
splashRadius: 16,
|
||||
icon: Icon(
|
||||
Icons.info,
|
||||
size: 20,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
onPressed: showBackupSelectionInfoDialog,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumSearchBar extends StatelessWidget {
|
||||
final Function(String) onSearch;
|
||||
|
||||
const _AlbumSearchBar({required this.onSearch});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
onChanged: onSearch,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.all(8.0),
|
||||
hintText: "Search",
|
||||
hintStyle: TextStyle(
|
||||
color: context.isDarkTheme ? Colors.white : Colors.grey,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Colors.grey,
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: context.isDarkTheme ? Colors.white30 : Colors.grey[200],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: prefer-sliver-prefix
|
||||
class _SilverLocalAlbumSelectionList extends StatelessWidget {
|
||||
final AsyncValue<List<LocalAlbum>> albums;
|
||||
|
||||
const _SilverLocalAlbumSelectionList({required this.albums});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (albums.isLoading) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (albums.hasError) {
|
||||
return SliverToBoxAdapter(child: Text("Error occured: ${albums.error}"));
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
sliver: context.isMobile
|
||||
? SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
((context, index) {
|
||||
return BackupAlbumInfoListItem(
|
||||
album: albums.requireValue.elementAt(index),
|
||||
);
|
||||
}),
|
||||
childCount: albums.requireValue.length,
|
||||
),
|
||||
)
|
||||
: SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 300,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
),
|
||||
itemCount: albums.requireValue.length,
|
||||
itemBuilder: ((context, index) {
|
||||
return BackupAlbumInfoListItem(
|
||||
album: albums.requireValue.elementAt(index),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -7,7 +6,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/extensions/object_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
@@ -15,7 +17,6 @@ import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.da
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -24,61 +25,71 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
|
||||
final backupState = ref.watch(backupProvider);
|
||||
final backupAlbums = ref.watch(backupAlbumsProvider);
|
||||
final deviceAssetState = ref.watch(deviceAssetsProvider);
|
||||
final hasAnyAlbum =
|
||||
backupAlbums.valueOrNull?.selectedBackupAlbums.isNotEmpty ?? false;
|
||||
|
||||
bool hasExclusiveAccess =
|
||||
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||
backupState.selectedAlbumsBackupAssetsIds.length ==
|
||||
0 ||
|
||||
bool shouldBackup = deviceAssetState.valueOrNull?.assetsRemaining == 0 ||
|
||||
!hasExclusiveAccess
|
||||
? false
|
||||
: true;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
|
||||
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||
ref.watch(backupProvider.notifier).getBackupInfo();
|
||||
}
|
||||
|
||||
// Update the background settings information just to make sure we
|
||||
// have the latest, since the platform channel will not update
|
||||
// automatically
|
||||
if (Platform.isIOS) {
|
||||
ref.watch(iOSBackgroundSettingsProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
ref
|
||||
.watch(websocketProvider.notifier)
|
||||
.stopListenToEvent('on_upload_success');
|
||||
ref.read(backupAlbumsProvider.notifier).refreshAlbumAssetsState();
|
||||
ref.invalidate(deviceAssetsProvider);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Widget buildSelectedAlbumName() {
|
||||
Future<String> getSelectedAlbumNames() async {
|
||||
var text = "backup_controller_page_backup_selected".tr();
|
||||
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||
|
||||
if (albums.isNotEmpty) {
|
||||
for (var album in albums) {
|
||||
if (album.name == "Recent" || album.name == "Recents") {
|
||||
text += "${album.name} (${'backup_all'.tr()}), ";
|
||||
} else {
|
||||
text += "${album.name}, ";
|
||||
}
|
||||
final selectedAlbums =
|
||||
backupAlbums.valueOrNull?.selectedBackupAlbums ?? [];
|
||||
for (final selected in selectedAlbums) {
|
||||
await selected.album.load();
|
||||
final album = selected.album.value;
|
||||
if (album == null) {
|
||||
continue;
|
||||
}
|
||||
if (album.name == "Recent" || album.name == "Recents") {
|
||||
text += "${album.name} (${'backup_all'.tr()}), ";
|
||||
} else {
|
||||
text += "${album.name}, ";
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
text.trim().substring(0, text.length - 2),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Widget buildSelectedAlbumName() {
|
||||
final selectedAlbums =
|
||||
backupAlbums.valueOrNull?.selectedBackupAlbums ?? [];
|
||||
|
||||
if (selectedAlbums.isNotEmpty) {
|
||||
return FutureBuilder(
|
||||
future: getSelectedAlbumNames(),
|
||||
builder: (_, data) => data.hasData
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
data.data!.trim().substring(0, data.data!.length - 2),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
} else {
|
||||
return Padding(
|
||||
@@ -93,23 +104,39 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildExcludedAlbumName() {
|
||||
Future<String> getExcludedAlbumNames() async {
|
||||
var text = "backup_controller_page_excluded".tr();
|
||||
var albums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||
|
||||
if (albums.isNotEmpty) {
|
||||
for (var album in albums) {
|
||||
text += "${album.name}, ";
|
||||
final excludedAlbums =
|
||||
backupAlbums.valueOrNull?.excludedBackupAlbums ?? [];
|
||||
for (final excluded in excludedAlbums) {
|
||||
excluded.album.loadSync();
|
||||
final album = excluded.album.value;
|
||||
if (album == null) {
|
||||
continue;
|
||||
}
|
||||
text += "${album.name}, ";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
text.trim().substring(0, text.length - 2),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: Colors.red[300],
|
||||
),
|
||||
),
|
||||
Widget buildExcludedAlbumName() {
|
||||
final excludedAlbums =
|
||||
backupAlbums.valueOrNull?.excludedBackupAlbums ?? [];
|
||||
|
||||
if (excludedAlbums.isNotEmpty) {
|
||||
return FutureBuilder(
|
||||
future: getExcludedAlbumNames(),
|
||||
builder: (_, data) => data.hasData
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
data.data!.trim().substring(0, data.data!.length - 2),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: Colors.red[300],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
@@ -121,7 +148,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
side: BorderSide(
|
||||
color: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 56, 56, 56)
|
||||
@@ -152,15 +179,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await context.pushRoute(const BackupAlbumSelectionRoute());
|
||||
// waited until returning from selection
|
||||
await ref
|
||||
.read(backupProvider.notifier)
|
||||
.backupAlbumSelectionDone();
|
||||
// waited until backup albums are stored in DB
|
||||
ref.read(albumProvider.notifier).getDeviceAlbums();
|
||||
},
|
||||
onPressed: () =>
|
||||
context.pushRoute(const BackupAlbumSelectionRoute()),
|
||||
child: const Text(
|
||||
"backup_controller_page_select",
|
||||
style: TextStyle(
|
||||
@@ -242,10 +262,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
"backup_controller_page_backup",
|
||||
).tr(),
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
||||
context.popRoute(true);
|
||||
},
|
||||
onPressed: () => context.popRoute(true),
|
||||
splashRadius: 24,
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_rounded,
|
||||
@@ -274,23 +291,32 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
BackupInfoCard(
|
||||
title: "backup_controller_page_total".tr(),
|
||||
subtitle: "backup_controller_page_total_sub".tr(),
|
||||
info: ref.watch(backupProvider).availableAlbums.isEmpty
|
||||
info: ref
|
||||
.watch(localAlbumsProvider)
|
||||
.valueOrNull
|
||||
.isNullOrEmpty
|
||||
? "..."
|
||||
: "${backupState.allUniqueAssets.length}",
|
||||
: "${deviceAssetState.valueOrNull?.uniqueAssetsToBackup ?? 0}",
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "backup_controller_page_backup".tr(),
|
||||
subtitle: "backup_controller_page_backup_sub".tr(),
|
||||
info: ref.watch(backupProvider).availableAlbums.isEmpty
|
||||
info: ref
|
||||
.watch(localAlbumsProvider)
|
||||
.valueOrNull
|
||||
.isNullOrEmpty
|
||||
? "..."
|
||||
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
: "${deviceAssetState.valueOrNull?.backedUpAssets ?? 0}",
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "backup_controller_page_remainder".tr(),
|
||||
subtitle: "backup_controller_page_remainder_sub".tr(),
|
||||
info: ref.watch(backupProvider).availableAlbums.isEmpty
|
||||
info: ref
|
||||
.watch(localAlbumsProvider)
|
||||
.valueOrNull
|
||||
.isNullOrEmpty
|
||||
? "..."
|
||||
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
|
||||
: "${deviceAssetState.valueOrNull?.assetsRemaining ?? 0}",
|
||||
),
|
||||
const Divider(),
|
||||
const CurrentUploadingAssetInfoBox(),
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/device_assets.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
|
||||
@@ -28,7 +28,8 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
const BackupOptionsPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
final deviceAssetState = ref.watch(deviceAssetsProvider);
|
||||
final backupSettings = ref.watch(backupSettingsProvider);
|
||||
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
|
||||
final settingsService = ref.watch(appSettingsServiceProvider);
|
||||
final showBackupFix = Platform.isAndroid &&
|
||||
@@ -64,8 +65,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
void performBackupCheck() async {
|
||||
try {
|
||||
checkInProgress.value = true;
|
||||
if (backupState.allUniqueAssets.length >
|
||||
backupState.selectedAlbumsBackupAssetsIds.length) {
|
||||
if (deviceAssetState.valueOrNull?.assetsRemaining != 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Backup all assets before starting this check!",
|
||||
@@ -194,9 +194,9 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Widget buildBackgroundBackupController() {
|
||||
final bool isBackgroundEnabled = backupState.backgroundBackup;
|
||||
final bool isWifiRequired = backupState.backupRequireWifi;
|
||||
final bool isChargingRequired = backupState.backupRequireCharging;
|
||||
final bool isBackgroundEnabled = backupSettings.backgroundBackup;
|
||||
final bool isWifiRequired = backupSettings.backupRequireWifi;
|
||||
final bool isChargingRequired = backupSettings.backupRequireCharging;
|
||||
final Color activeColor = context.primaryColor;
|
||||
|
||||
String formatBackupDelaySliderValue(double v) {
|
||||
@@ -236,7 +236,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final triggerDelay =
|
||||
useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
|
||||
useState(backupDelayToSliderValue(backupSettings.backupTriggerDelay));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -276,7 +276,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
activeColor: activeColor,
|
||||
value: isWifiRequired,
|
||||
onChanged: (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.read(backupSettingsProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireWifi: isChecked,
|
||||
onError: showErrorToUser,
|
||||
@@ -296,7 +296,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
activeColor: activeColor,
|
||||
value: isChargingRequired,
|
||||
onChanged: (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.read(backupSettingsProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireCharging: isChecked,
|
||||
onError: showErrorToUser,
|
||||
@@ -319,7 +319,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
value: triggerDelay.value,
|
||||
onChanged: (double v) => triggerDelay.value = v,
|
||||
onChangeEnd: (double v) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.read(backupSettingsProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
triggerDelay: backupDelayToMilliseconds(v),
|
||||
onError: showErrorToUser,
|
||||
@@ -333,7 +333,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref
|
||||
.read(backupProvider.notifier)
|
||||
.read(backupSettingsProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
enabled: !isBackgroundEnabled,
|
||||
onError: showErrorToUser,
|
||||
@@ -411,7 +411,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
ListTile buildAutoBackupController() {
|
||||
final isAutoBackup = backupState.autoBackup;
|
||||
final isAutoBackup = backupSettings.autoBackup;
|
||||
final backUpOption = isAutoBackup
|
||||
? "backup_controller_page_status_on".tr()
|
||||
: "backup_controller_page_status_off".tr();
|
||||
@@ -443,7 +443,7 @@ class BackupOptionsPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => ref
|
||||
.read(backupProvider.notifier)
|
||||
.read(backupSettingsProvider.notifier)
|
||||
.setAutoBackup(!isAutoBackup),
|
||||
child: Text(
|
||||
backupBtnText,
|
||||
|
||||
@@ -2,7 +2,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/modules/home/models/selection_state.dart';
|
||||
@@ -10,7 +11,6 @@ import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class ControlBottomAppBar extends ConsumerWidget {
|
||||
final void Function(bool shareLocal) onShare;
|
||||
@@ -19,7 +19,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
final void Function([bool force])? onDelete;
|
||||
final void Function([bool force])? onDeleteServer;
|
||||
final void Function(bool onlyBackedUp)? onDeleteLocal;
|
||||
final Function(Album album) onAddToAlbum;
|
||||
final Function(RemoteAlbum album) onAddToAlbum;
|
||||
final void Function() onCreateNewAlbum;
|
||||
final void Function() onUpload;
|
||||
final void Function()? onStack;
|
||||
@@ -61,7 +61,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
selectionAssetState.hasLocal || selectionAssetState.hasMerged;
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
final albums = ref.watch(remoteAlbumsProvider).valueOrNull ?? [];
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
const bottomPadding = 0.20;
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
|
||||
@@ -34,8 +35,9 @@ class HomePage extends HookConsumerWidget {
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
Future(() => ref.read(assetProvider.notifier).getAllAsset());
|
||||
ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
|
||||
ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
@@ -116,7 +116,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
_ref.invalidate(albumProvider);
|
||||
_ref.invalidate(remoteAlbumsProvider);
|
||||
_ref.invalidate(sharedAlbumProvider);
|
||||
|
||||
state = state.copyWith(
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ part of 'map_state.provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52';
|
||||
String _$mapStateNotifierHash() => r'6408d616ec9fc0d1ff26e25692417c43504ff754';
|
||||
|
||||
/// See also [MapStateNotifier].
|
||||
@ProviderFor(MapStateNotifier)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState;
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
|
||||
class LocalStorageSettings extends HookConsumerWidget {
|
||||
const LocalStorageSettings({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isarDb = ref.watch(dbProvider);
|
||||
final cacheItemCount = useState(0);
|
||||
useEffect(
|
||||
() {
|
||||
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void clearCache() {
|
||||
isarDb.writeTxnSync(() => isarDb.duplicatedAssets.clearSync());
|
||||
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
|
||||
}
|
||||
|
||||
return ExpansionTile(
|
||||
textColor: context.primaryColor,
|
||||
title: Text(
|
||||
"cache_settings_tile_title",
|
||||
style: context.textTheme.titleMedium,
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
"cache_settings_tile_subtitle",
|
||||
).tr(),
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
"cache_settings_duplicated_assets_title",
|
||||
style: context.textTheme.titleSmall,
|
||||
).tr(args: ["${cacheItemCount.value}"]),
|
||||
subtitle: const Text(
|
||||
"cache_settings_duplicated_assets_subtitle",
|
||||
).tr(),
|
||||
trailing: TextButton(
|
||||
onPressed: cacheItemCount.value > 0 ? clearCache : null,
|
||||
child: Text(
|
||||
"cache_settings_duplicated_assets_clear_button",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cacheItemCount.value > 0 ? Colors.red : Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/advanced_settings/advanced_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/local_storage_settings/local_storage_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||
@@ -40,7 +39,6 @@ class SettingsPage extends HookConsumerWidget {
|
||||
const AssetListSettings(),
|
||||
const NotificationSetting(),
|
||||
// const ExperimentalSettings(),
|
||||
const LocalStorageSettings(),
|
||||
const AdvancedSettings(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/views/activities_page.dart';
|
||||
import 'package:immich_mobile/modules/album/models/album.model.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_options_part.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_options_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/remote_album_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||
@@ -21,7 +22,7 @@ import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/archive/views/archive_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/local_album_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
|
||||
@@ -49,7 +50,6 @@ import 'package:immich_mobile/routing/custom_transition_builders.dart';
|
||||
import 'package:immich_mobile/routing/duplicate_guard.dart';
|
||||
import 'package:immich_mobile/routing/backup_permission_guard.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/logger_message.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
@@ -60,7 +60,6 @@ import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' hide LatLng;
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
@@ -157,7 +156,7 @@ class AppRouter extends _$AppRouter {
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
AutoRoute(
|
||||
page: AlbumViewerRoute.page,
|
||||
page: RemoteAlbumViewerRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
CustomRoute(
|
||||
@@ -170,7 +169,7 @@ class AppRouter extends _$AppRouter {
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: AlbumPreviewRoute.page,
|
||||
page: LocalAlbumViewerRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
CustomRoute(
|
||||
|
||||
+108
-102
@@ -31,26 +31,6 @@ abstract class _$AppRouter extends RootStackRouter {
|
||||
),
|
||||
);
|
||||
},
|
||||
AlbumPreviewRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AlbumPreviewRouteArgs>();
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: AlbumPreviewPage(
|
||||
key: args.key,
|
||||
album: args.album,
|
||||
),
|
||||
);
|
||||
},
|
||||
AlbumViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AlbumViewerRouteArgs>();
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: AlbumViewerPage(
|
||||
key: args.key,
|
||||
albumId: args.albumId,
|
||||
),
|
||||
);
|
||||
},
|
||||
AllMotionPhotosRoute.name: (routeData) {
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
@@ -182,6 +162,17 @@ abstract class _$AppRouter extends RootStackRouter {
|
||||
child: const LibraryPage(),
|
||||
);
|
||||
},
|
||||
LocalAlbumViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<LocalAlbumViewerRouteArgs>();
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: LocalAlbumViewerPage(
|
||||
key: args.key,
|
||||
album: args.album,
|
||||
selectEnabled: args.selectEnabled,
|
||||
),
|
||||
);
|
||||
},
|
||||
LoginRoute.name: (routeData) {
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
@@ -255,6 +246,16 @@ abstract class _$AppRouter extends RootStackRouter {
|
||||
child: const RecentlyAddedPage(),
|
||||
);
|
||||
},
|
||||
RemoteAlbumViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<RemoteAlbumViewerRouteArgs>();
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: RemoteAlbumViewerPage(
|
||||
key: args.key,
|
||||
albumId: args.albumId,
|
||||
),
|
||||
);
|
||||
},
|
||||
SearchRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<SearchRouteArgs>(
|
||||
orElse: () => const SearchRouteArgs());
|
||||
@@ -382,7 +383,7 @@ class ActivitiesRoute extends PageRouteInfo<void> {
|
||||
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
|
||||
AlbumOptionsRoute({
|
||||
Key? key,
|
||||
required Album album,
|
||||
required RemoteAlbum album,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
AlbumOptionsRoute.name,
|
||||
@@ -407,7 +408,7 @@ class AlbumOptionsRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -415,82 +416,6 @@ class AlbumOptionsRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AlbumPreviewPage]
|
||||
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
|
||||
AlbumPreviewRoute({
|
||||
Key? key,
|
||||
required AssetPathEntity album,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
AlbumPreviewRoute.name,
|
||||
args: AlbumPreviewRouteArgs(
|
||||
key: key,
|
||||
album: album,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'AlbumPreviewRoute';
|
||||
|
||||
static const PageInfo<AlbumPreviewRouteArgs> page =
|
||||
PageInfo<AlbumPreviewRouteArgs>(name);
|
||||
}
|
||||
|
||||
class AlbumPreviewRouteArgs {
|
||||
const AlbumPreviewRouteArgs({
|
||||
this.key,
|
||||
required this.album,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final AssetPathEntity album;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AlbumPreviewRouteArgs{key: $key, album: $album}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AlbumViewerPage]
|
||||
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
||||
AlbumViewerRoute({
|
||||
Key? key,
|
||||
required int albumId,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
AlbumViewerRoute.name,
|
||||
args: AlbumViewerRouteArgs(
|
||||
key: key,
|
||||
albumId: albumId,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'AlbumViewerRoute';
|
||||
|
||||
static const PageInfo<AlbumViewerRouteArgs> page =
|
||||
PageInfo<AlbumViewerRouteArgs>(name);
|
||||
}
|
||||
|
||||
class AlbumViewerRouteArgs {
|
||||
const AlbumViewerRouteArgs({
|
||||
this.key,
|
||||
required this.albumId,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final int albumId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AlbumViewerRouteArgs{key: $key, albumId: $albumId}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AllMotionPhotosPage]
|
||||
class AllMotionPhotosRoute extends PageRouteInfo<void> {
|
||||
@@ -874,6 +799,49 @@ class LibraryRoute extends PageRouteInfo<void> {
|
||||
static const PageInfo<void> page = PageInfo<void>(name);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LocalAlbumViewerPage]
|
||||
class LocalAlbumViewerRoute extends PageRouteInfo<LocalAlbumViewerRouteArgs> {
|
||||
LocalAlbumViewerRoute({
|
||||
Key? key,
|
||||
required LocalAlbum album,
|
||||
bool selectEnabled = true,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
LocalAlbumViewerRoute.name,
|
||||
args: LocalAlbumViewerRouteArgs(
|
||||
key: key,
|
||||
album: album,
|
||||
selectEnabled: selectEnabled,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'LocalAlbumViewerRoute';
|
||||
|
||||
static const PageInfo<LocalAlbumViewerRouteArgs> page =
|
||||
PageInfo<LocalAlbumViewerRouteArgs>(name);
|
||||
}
|
||||
|
||||
class LocalAlbumViewerRouteArgs {
|
||||
const LocalAlbumViewerRouteArgs({
|
||||
this.key,
|
||||
required this.album,
|
||||
this.selectEnabled = true,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final LocalAlbum album;
|
||||
|
||||
final bool selectEnabled;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LocalAlbumViewerRouteArgs{key: $key, album: $album, selectEnabled: $selectEnabled}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LoginPage]
|
||||
class LoginRoute extends PageRouteInfo<void> {
|
||||
@@ -1105,6 +1073,44 @@ class RecentlyAddedRoute extends PageRouteInfo<void> {
|
||||
static const PageInfo<void> page = PageInfo<void>(name);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [RemoteAlbumViewerPage]
|
||||
class RemoteAlbumViewerRoute extends PageRouteInfo<RemoteAlbumViewerRouteArgs> {
|
||||
RemoteAlbumViewerRoute({
|
||||
Key? key,
|
||||
required int albumId,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
RemoteAlbumViewerRoute.name,
|
||||
args: RemoteAlbumViewerRouteArgs(
|
||||
key: key,
|
||||
albumId: albumId,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'RemoteAlbumViewerRoute';
|
||||
|
||||
static const PageInfo<RemoteAlbumViewerRouteArgs> page =
|
||||
PageInfo<RemoteAlbumViewerRouteArgs>(name);
|
||||
}
|
||||
|
||||
class RemoteAlbumViewerRouteArgs {
|
||||
const RemoteAlbumViewerRouteArgs({
|
||||
this.key,
|
||||
required this.albumId,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final int albumId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RemoteAlbumViewerRouteArgs{key: $key, albumId: $albumId}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SearchPage]
|
||||
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
|
||||
@@ -1177,7 +1183,7 @@ class SelectAdditionalUserForSharingRoute
|
||||
extends PageRouteInfo<SelectAdditionalUserForSharingRouteArgs> {
|
||||
SelectAdditionalUserForSharingRoute({
|
||||
Key? key,
|
||||
required Album album,
|
||||
required RemoteAlbum album,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
SelectAdditionalUserForSharingRoute.name,
|
||||
@@ -1202,7 +1208,7 @@ class SelectAdditionalUserForSharingRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Album album;
|
||||
final RemoteAlbum album;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -1393,7 +1399,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
void Function()? onPaused,
|
||||
Widget? placeholder,
|
||||
bool showControls = true,
|
||||
Duration hideControlsTimer = const Duration(milliseconds: 1500),
|
||||
Duration hideControlsTimer = const Duration(seconds: 5),
|
||||
bool showDownloadingIndicator = true,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
@@ -1429,7 +1435,7 @@ class VideoViewerRouteArgs {
|
||||
this.onPaused,
|
||||
this.placeholder,
|
||||
this.showControls = true,
|
||||
this.hideControlsTimer = const Duration(milliseconds: 1500),
|
||||
this.hideControlsTimer = const Duration(seconds: 5),
|
||||
this.showDownloadingIndicator = true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/local_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
|
||||
|
||||
@@ -47,7 +48,8 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
}
|
||||
|
||||
if (route.name == 'LibraryRoute') {
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(remoteAlbumsProvider.notifier).getRemoteAlbums();
|
||||
ref.read(localAlbumsProvider.notifier).getDeviceAlbums();
|
||||
}
|
||||
|
||||
if (route.name == 'HomeRoute') {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user