merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-01-21 17:02:22 +00:00
310 changed files with 18273 additions and 3199 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.4", "@types/node": "^24.10.8",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
+7 -2
View File
@@ -17,12 +17,17 @@ Hardware and software requirements for Immich:
- Immich runs well in a virtualized environment when running in a full virtual machine. - Immich runs well in a virtualized environment when running in a full virtual machine.
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users. The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
If you have issues, we recommend that you switch to a supported VM deployment. If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 4GB, recommended 6GB. - **RAM**: Minimum 6GB, recommended 8GB.
- **CPU**: Minimum 2 cores, recommended 4 cores. - **CPU**: Minimum 2 cores, recommended 4 cores.
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
:::tip :::note RAM requirements
For a smooth experience, especially during asset upload, Immich requires at least 6GB of RAM.
For systems with only 4GB of RAM, Immich can be run with machine learning features disabled.
:::
:::tip Postgres setup
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience. Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
The Postgres database files are typically between 1-3 GB in size. The Postgres database files are typically between 1-3 GB in size.
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind. For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.
+1 -1
View File
@@ -27,7 +27,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^24.10.4", "@types/node": "^24.10.8",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
+8 -8
View File
@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1'; export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1'; export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`; export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0'); export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER; export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1'; process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/, testMatch: /.*\.e2e-spec\.ts/,
workers: 1, workers: 1,
}, },
{ // {
name: 'parallel tests', // name: 'parallel tests',
use: { ...devices['Desktop Chrome'] }, // use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/, // testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true, // fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), // workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
}, // },
// { // {
// name: 'firefox', // name: 'firefox',
@@ -0,0 +1,350 @@
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.resetBackups(admin.accessToken);
});
describe('GET /', async () => {
it('should succeed and be empty', async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
backups: [],
});
});
it('should contain a created backup', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.BackupDatabase,
});
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
await expect
.poll(
async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
backups: [
expect.objectContaining({
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
filesize: expect.any(Number),
}),
],
}),
);
});
});
describe('DELETE /', async () => {
it('should delete backup', async () => {
const filename = await utils.createBackup(admin.accessToken);
const { status } = await request(app)
.delete(`/admin/database-backups`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ backups: [filename] });
expect(status).toBe(200);
const { status: listStatus, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(listStatus).toBe(200);
expect(body).toEqual(
expect.objectContaining({
backups: [],
}),
);
});
});
// => action: restore database flow
describe.sequential('POST /start-restore', () => {
afterAll(async () => {
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup();
});
it.sequential('should not work when the server is configured', async () => {
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
});
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
await utils.resetDatabase(); // reset database before running this test
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual({
active: true,
action: 'select_database_restore',
});
});
});
// => action: restore database
describe.sequential('POST /backups/restore', () => {
beforeAll(async () => {
await utils.disconnectDatabase();
});
afterAll(async () => {
await utils.connectDatabase();
});
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
let filename = await utils.createBackup(admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: filename,
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
}),
);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();
});
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('corrupted');
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-corrupted.sql.gz',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);
const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('IM CORRUPTED'),
}),
);
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('empty');
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-empty.sql.gz',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 30_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);
const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('Server health check failed, no admin exists.'),
}),
);
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
});
});
+29 -2
View File
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
await utils.resetBackups(admin.accessToken);
}); });
// => outside of maintenance mode // => outside of maintenance mode
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
}); });
}); });
describe('GET /status', async () => {
it('to always indicate we are not in maintenance mode', async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
expect(body).toEqual({
active: false,
action: 'end',
});
});
});
describe('POST /login', async () => { describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => { it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' }); const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
describe.sequential('POST /', () => { describe.sequential('POST /', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({ const { status, body } = await request(app).post('/admin/maintenance').send({
active: false,
action: 'end', action: 'end',
}); });
expect(status).toBe(401); expect(status).toBe(401);
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
.send({ .send({
action: 'start', action: 'start',
}); });
expect(status).toBe(201); expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0]; cookie = headers['set-cookie'][0].split(';')[0];
@@ -79,7 +93,8 @@ describe('/admin/maintenance', () => {
await expect await expect
.poll( .poll(
async () => { async () => {
const { body } = await request(app).get('/server/config'); const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode; return body.maintenanceMode;
}, },
{ {
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
}); });
}); });
describe('GET /status', async () => {
it('to indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
expect(body).toEqual({
active: true,
action: 'start',
});
});
});
describe('POST /login', async () => { describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => { it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({}); const { status, body } = await request(app).post('/admin/maintenance/login').send({});
@@ -158,7 +184,8 @@ describe('/admin/maintenance', () => {
await expect await expect
.poll( .poll(
async () => { async () => {
const { body } = await request(app).get('/server/config'); const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode; return body.maintenanceMode;
}, },
{ {
@@ -348,6 +348,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
checksum: asset.checksum, checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1, width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1, height: exifInfo.exifImageHeight ?? 1,
isEdited: false,
}; };
} }
+74 -6
View File
@@ -8,6 +8,7 @@ import {
CreateLibraryDto, CreateLibraryDto,
JobCreateDto, JobCreateDto,
MaintenanceAction, MaintenanceAction,
ManualJobName,
MetadataSearchDto, MetadataSearchDto,
Permission, Permission,
PersonCreateDto, PersonCreateDto,
@@ -30,10 +31,12 @@ import {
createStack, createStack,
createUserAdmin, createUserAdmin,
deleteAssets, deleteAssets,
deleteDatabaseBackup,
getAssetInfo, getAssetInfo,
getConfig, getConfig,
getConfigDefaults, getConfigDefaults,
getQueuesLegacy, getQueuesLegacy,
listDatabaseBackups,
login, login,
runQueueCommandLegacy, runQueueCommandLegacy,
scanLibrary, scanLibrary,
@@ -62,6 +65,7 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises'; import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { createGzip } from 'node:zlib';
import pg from 'pg'; import pg from 'pg';
import { io, type Socket } from 'socket.io-client'; import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures'; import { loginDto, signupDto } from 'src/fixtures';
@@ -89,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
export const immichCli = (args: string[]) => export const immichCli = (args: string[]) =>
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise; executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
export const immichAdmin = (args: string[]) => export const dockerExec = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*']; export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -154,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
}; };
export const utils = { export const utils = {
connectDatabase: async () => {
if (!client) {
client = new pg.Client(dbUrl);
client.on('end', () => (client = null));
client.on('error', () => (client = null));
await client.connect();
}
return client;
},
disconnectDatabase: async () => {
if (client) {
await client.end();
}
},
resetDatabase: async (tables?: string[]) => { resetDatabase: async (tables?: string[]) => {
try { try {
if (!client) { client = await utils.connectDatabase();
client = new pg.Client(dbUrl);
await client.connect();
}
tables = tables || [ tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex // TODO e2e test for deleting a stack, since it is quite complex
@@ -616,6 +635,36 @@ export const utils = {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mkdir', '-p', path]).promise; return executeCommand('docker', ['exec', 'immich-e2e-server', 'mkdir', '-p', path]).promise;
}, },
createBackup: async (accessToken: string) => {
await utils.createJob(accessToken, {
name: ManualJobName.BackupDatabase,
});
return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1,
({ body }) => body.backups[0].filename,
);
},
resetBackups: async (accessToken: string) => {
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
await deleteDatabaseBackup({ databaseBackupDeleteDto: { backups } }, { headers: asBearerAuth(accessToken) });
},
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
const dir = await mkdtemp(join(tmpdir(), 'test-'));
const fn = join(dir, 'file');
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
const gzip = createGzip();
const writeStream = createWriteStream(fn);
await pipeline(sql, gzip, writeStream);
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
.promise;
},
resetAdminConfig: async (accessToken: string) => { resetAdminConfig: async (accessToken: string) => {
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) }); const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
@@ -658,6 +707,25 @@ export const utils = {
await utils.waitForQueueFinish(accessToken, 'sidecar'); await utils.waitForQueueFinish(accessToken, 'sidecar');
await utils.waitForQueueFinish(accessToken, 'metadataExtraction'); await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
}, },
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
let timeout = 0;
while (true) {
try {
const data = await cb();
if (validate(data)) {
return map ? map(data) : data;
}
timeout++;
if (timeout >= 10) {
throw 'Could not clean up test.';
}
await new Promise((resolve) => setTimeout(resolve, 5e2));
} catch {
// no-op
}
}
},
}; };
utils.initSdk(); utils.initSdk();
@@ -12,7 +12,7 @@ import {
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils'; import { assetViewerUtils } from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => { test.describe('asset-viewer', () => {
@@ -49,7 +49,6 @@ test.describe('asset-viewer', () => {
}); });
test.afterEach(() => { test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false; testContext.slowBucket = false;
changes.albumAdditions = []; changes.albumAdditions = [];
changes.assetDeletions = []; changes.assetDeletions = [];
@@ -0,0 +1,105 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Database Backups', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('restore a backup from settings', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
const filename = await utils.createBackup(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
});
test('handle backup restore failure', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
await utils.prepareTestBackup('corrupted');
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/maintenance**');
});
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
await utils.prepareTestBackup('empty');
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/maintenance**');
});
test('restore a backup from onboarding', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
const filename = await utils.createBackup(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
await utils.resetDatabase();
await page.goto('/');
await page.getByRole('button', { name: 'Restore from backup' }).click();
try {
await page.waitForURL('/maintenance**');
} catch {
// when chained with the rest of the tests
// this navigation may fail..? not sure why...
await page.goto('/maintenance');
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/photos', { timeout: 60_000 });
});
});
+3 -3
View File
@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
test('enter and exit maintenance mode', async ({ context, page }) => { test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken); await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance'); await page.goto('/admin/maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click(); await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click(); await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 }); await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
}); });
test('maintenance shows no options to users until they authenticate', async ({ page }) => { test('maintenance shows no options to users until they authenticate', async ({ page }) => {
@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
import { utils } from 'src/utils'; import { utils } from 'src/utils';
import { import {
assetViewerUtils, assetViewerUtils,
cancelAllPollers,
padYearMonth, padYearMonth,
pageUtils, pageUtils,
poll, poll,
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
}); });
test.afterEach(() => { test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false; testContext.slowBucket = false;
changes.albumAdditions = []; changes.albumAdditions = [];
changes.assetDeletions = []; changes.assetDeletions = [];
-14
View File
@@ -23,13 +23,6 @@ export async function throttlePage(context: BrowserContext, page: Page) {
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 }); await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
} }
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>( export const poll = async <T>(
page: Page, page: Page,
query: () => Promise<T>, query: () => Promise<T>,
@@ -37,21 +30,14 @@ export const poll = async <T>(
) => { ) => {
let result; let result;
const timeout = Date.now() + 10_000; const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result); const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) { while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try { try {
result = await query(); result = await query();
} catch { } catch {
// ignore // ignore
} }
if (signal.aborted) {
return;
}
if (page.isClosed()) { if (page.isClosed()) {
return; return;
} }
+34 -3
View File
@@ -189,6 +189,9 @@
"machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.", "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_delete_backup": "Delete Backup",
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
"maintenance_delete_error": "Failed to delete backup.",
"maintenance_integrity_check_all": "Check All", "maintenance_integrity_check_all": "Check All",
"maintenance_integrity_checksum_mismatch": "Checksum Mismatch", "maintenance_integrity_checksum_mismatch": "Checksum Mismatch",
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches", "maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
@@ -200,10 +203,18 @@
"maintenance_integrity_untracked_file": "Untracked Files", "maintenance_integrity_untracked_file": "Untracked Files",
"maintenance_integrity_untracked_file_job": "Check for untracked files", "maintenance_integrity_untracked_file_job": "Check for untracked files",
"maintenance_integrity_untracked_file_refresh_job": "Refresh untracked file reports", "maintenance_integrity_untracked_file_refresh_job": "Refresh untracked file reports",
"maintenance_restore_backup": "Restore Backup",
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
"maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
"maintenance_restore_backup_unknown_version": "Couldn't determine backup version.",
"maintenance_restore_database_backup": "Restore database backup",
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
"maintenance_settings": "Maintenance", "maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.", "maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Start maintenance mode", "maintenance_start": "Switch to maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.", "maintenance_start_error": "Failed to start maintenance mode.",
"maintenance_upload_backup": "Upload database backup file",
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
"manage_concurrency": "Manage Concurrency", "manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency", "manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings", "manage_log_settings": "Manage log settings",
@@ -615,7 +626,7 @@
"backup_album_selection_page_select_albums": "Select albums", "backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info", "backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets", "backup_album_selection_page_total_assets": "Total unique assets",
"backup_albums_sync": "Backup albums synchronization", "backup_albums_sync": "Backup Albums Synchronization",
"backup_all": "All", "backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_complete_notification": "Asset backup complete", "backup_background_service_complete_notification": "Asset backup complete",
@@ -940,6 +951,7 @@
"download_include_embedded_motion_videos": "Embedded videos", "download_include_embedded_motion_videos": "Embedded videos",
"download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
"download_notfound": "Download not found", "download_notfound": "Download not found",
"download_original": "Download original",
"download_paused": "Download paused", "download_paused": "Download paused",
"download_settings": "Download", "download_settings": "Download",
"download_settings_description": "Manage settings related to asset download", "download_settings_description": "Manage settings related to asset download",
@@ -1418,10 +1430,28 @@
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.", "loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu", "main_menu": "Main menu",
"maintenance_action_restore": "Restoring Database",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.", "maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode", "maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.", "maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}", "maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_restore_from_backup": "Restore From Backup",
"maintenance_restore_library": "Restore Your Library",
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
"maintenance_restore_library_description": "Restoring Database",
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
"maintenance_restore_library_folder_pass": "readable and writable",
"maintenance_restore_library_folder_read_fail": "not readable",
"maintenance_restore_library_folder_write_fail": "not writable",
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
"maintenance_task_backup": "Creating a backup of the existing database…",
"maintenance_task_migrations": "Running database migrations…",
"maintenance_task_restore": "Restoring the chosen backup…",
"maintenance_task_rollback": "Restore failed, rolling back to restore point…",
"maintenance_title": "Temporarily Unavailable", "maintenance_title": "Temporarily Unavailable",
"make": "Make", "make": "Make",
"manage_geolocation": "Manage location", "manage_geolocation": "Manage location",
@@ -2229,6 +2259,7 @@
"unhide_person": "Unhide person", "unhide_person": "Unhide person",
"unknown": "Unknown", "unknown": "Unknown",
"unknown_country": "Unknown Country", "unknown_country": "Unknown Country",
"unknown_date": "Unknown date",
"unknown_year": "Unknown Year", "unknown_year": "Unknown Year",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video", "unlink_motion_video": "Unlink motion video",
@@ -2271,7 +2302,7 @@
"url": "URL", "url": "URL",
"usage": "Usage", "usage": "Usage",
"use_biometric": "Use biometric", "use_biometric": "Use biometric",
"use_current_connection": "use current connection", "use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead", "use_custom_date_range": "Use custom date range instead",
"user": "User", "user": "User",
"user_has_been_deleted": "This user has been deleted.", "user_has_been_deleted": "This user has been deleted.",
+4 -4
View File
@@ -92,14 +92,14 @@ FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-0_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file # TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \ dpkg -i *.deb && \
rm *.deb && \ rm *.deb && \
apt-get remove wget -yqq && \ apt-get remove wget -yqq && \
+1 -1
View File
@@ -3,7 +3,7 @@ experimental_monorepo_root = true
[tools] [tools]
node = "24.13.0" node = "24.13.0"
flutter = "3.35.7" flutter = "3.35.7"
pnpm = "10.27.0" pnpm = "10.28.0"
terragrunt = "0.93.10" terragrunt = "0.93.10"
opentofu = "1.10.7" opentofu = "1.10.7"
java = "25.0.1" java = "25.0.1"
@@ -117,6 +117,9 @@
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/memories/" /> android:pathPrefix="/memories/" />
<data
android:host="my.immich.app"
android:path="/memory" />
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/photos/" /> android:pathPrefix="/photos/" />
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@ sealed class BaseAsset {
final int? durationInSeconds; final int? durationInSeconds;
final bool isFavorite; final bool isFavorite;
final String? livePhotoVideoId; final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({ const BaseAsset({
required this.name, required this.name,
@@ -34,6 +35,7 @@ sealed class BaseAsset {
this.durationInSeconds, this.durationInSeconds,
this.isFavorite = false, this.isFavorite = false,
this.livePhotoVideoId, this.livePhotoVideoId,
required this.isEdited,
}); });
bool get isImage => type == AssetType.image; bool get isImage => type == AssetType.image;
@@ -71,6 +73,7 @@ sealed class BaseAsset {
height: ${height ?? "<NA>"}, height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"}, durationInSeconds: ${durationInSeconds ?? "<NA>"},
isFavorite: $isFavorite, isFavorite: $isFavorite,
isEdited: $isEdited,
}'''; }''';
} }
@@ -85,7 +88,8 @@ sealed class BaseAsset {
width == other.width && width == other.width &&
height == other.height && height == other.height &&
durationInSeconds == other.durationInSeconds && durationInSeconds == other.durationInSeconds &&
isFavorite == other.isFavorite; isFavorite == other.isFavorite &&
isEdited == other.isEdited;
} }
return false; return false;
} }
@@ -99,6 +103,7 @@ sealed class BaseAsset {
width.hashCode ^ width.hashCode ^
height.hashCode ^ height.hashCode ^
durationInSeconds.hashCode ^ durationInSeconds.hashCode ^
isFavorite.hashCode; isFavorite.hashCode ^
isEdited.hashCode;
} }
} }
@@ -28,6 +28,7 @@ class LocalAsset extends BaseAsset {
this.adjustmentTime, this.adjustmentTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
required super.isEdited,
}) : remoteAssetId = remoteId; }) : remoteAssetId = remoteId;
@override @override
@@ -107,6 +108,7 @@ class LocalAsset extends BaseAsset {
DateTime? adjustmentTime, DateTime? adjustmentTime,
double? latitude, double? latitude,
double? longitude, double? longitude,
bool? isEdited,
}) { }) {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -125,6 +127,7 @@ class LocalAsset extends BaseAsset {
adjustmentTime: adjustmentTime ?? this.adjustmentTime, adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
); );
} }
} }
@@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited,
}) : localAssetId = localId; }) : localAssetId = localId;
@override @override
@@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset {
AssetVisibility? visibility, AssetVisibility? visibility,
String? livePhotoVideoId, String? livePhotoVideoId,
String? stackId, String? stackId,
bool? isEdited,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset {
visibility: visibility ?? this.visibility, visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
); );
} }
} }
@@ -436,5 +436,6 @@ extension PlatformToLocalAsset on PlatformAsset {
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true), adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
isEdited: false,
); );
} }
@@ -77,6 +77,7 @@ extension on AssetResponseDto {
thumbHash: thumbhash, thumbHash: thumbhash,
localId: null, localId: null,
type: type.toAssetType(), type: type.toAssetType(),
isEdited: isEdited,
); );
} }
} }
@@ -247,6 +247,42 @@ class SyncStreamService {
} }
} }
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
if (assetData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
if (asset != null) {
assets.add(asset);
}
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async { Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) { if (checksums.isEmpty) {
return Future.value(); return Future.value();
@@ -196,6 +196,16 @@ class BackgroundSyncManager {
}); });
} }
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() { Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) { if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future; return _linkedAlbumSyncTask!.future;
@@ -231,3 +241,8 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData), computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData),
debugLabel: 'websocket-batch', debugLabel: 'websocket-batch',
); );
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
debugLabel: 'websocket-edit',
);
@@ -47,5 +47,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
cloudId: iCloudId, cloudId: iCloudId,
isEdited: false,
); );
} }
@@ -25,7 +25,8 @@ SELECT
NULL as i_cloud_id, NULL as i_cloud_id,
NULL as latitude, NULL as latitude,
NULL as longitude, NULL as longitude,
NULL as adjustmentTime NULL as adjustmentTime,
rae.is_edited
FROM FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
@@ -61,7 +62,8 @@ SELECT
lae.i_cloud_id, lae.i_cloud_id,
lae.latitude, lae.latitude,
lae.longitude, lae.longitude,
lae.adjustment_time lae.adjustment_time,
0 as is_edited
FROM FROM
local_asset_entity lae local_asset_entity lae
WHERE NOT EXISTS ( WHERE NOT EXISTS (
+4 -1
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
); );
$arrayStartIndex += generatedlimit.amountOfVariables; $arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect( return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}', 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [ variables: [
for (var $ in userIds) i0.Variable<String>($), for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables, ...generatedlimit.introducedVariables,
@@ -66,6 +66,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
latitude: row.readNullable<double>('latitude'), latitude: row.readNullable<double>('latitude'),
longitude: row.readNullable<double>('longitude'), longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'), adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
isEdited: row.read<bool>('is_edited'),
), ),
); );
} }
@@ -137,6 +138,7 @@ class MergedAssetResult {
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final DateTime? adjustmentTime; final DateTime? adjustmentTime;
final bool isEdited;
MergedAssetResult({ MergedAssetResult({
this.remoteId, this.remoteId,
this.localId, this.localId,
@@ -158,6 +160,7 @@ class MergedAssetResult {
this.latitude, this.latitude,
this.longitude, this.longitude,
this.adjustmentTime, this.adjustmentTime,
required this.isEdited,
}); });
} }
@@ -44,6 +44,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
TextColumn get libraryId => text().nullable()(); TextColumn get libraryId => text().nullable()();
BoolColumn get isEdited => boolean().withDefault(const Constant(false))();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -66,5 +68,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
localId: localId, localId: localId,
stackId: stackId, stackId: stackId,
isEdited: isEdited,
); );
} }
@@ -31,6 +31,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder =
required i2.AssetVisibility visibility, required i2.AssetVisibility visibility,
i0.Value<String?> stackId, i0.Value<String?> stackId,
i0.Value<String?> libraryId, i0.Value<String?> libraryId,
i0.Value<bool> isEdited,
}); });
typedef $$RemoteAssetEntityTableUpdateCompanionBuilder = typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i1.RemoteAssetEntityCompanion Function({ i1.RemoteAssetEntityCompanion Function({
@@ -52,6 +53,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i0.Value<i2.AssetVisibility> visibility, i0.Value<i2.AssetVisibility> visibility,
i0.Value<String?> stackId, i0.Value<String?> stackId,
i0.Value<String?> libraryId, i0.Value<String?> libraryId,
i0.Value<bool> isEdited,
}); });
final class $$RemoteAssetEntityTableReferences final class $$RemoteAssetEntityTableReferences
@@ -196,6 +198,11 @@ class $$RemoteAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i0.ColumnFilters<bool> get isEdited => $composableBuilder(
column: $table.isEdited,
builder: (column) => i0.ColumnFilters(column),
);
i5.$$UserEntityTableFilterComposer get ownerId { i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder( final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -318,6 +325,11 @@ class $$RemoteAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<bool> get isEdited => $composableBuilder(
column: $table.isEdited,
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$UserEntityTableOrderingComposer get ownerId { i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder( final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -417,6 +429,9 @@ class $$RemoteAssetEntityTableAnnotationComposer
i0.GeneratedColumn<String> get libraryId => i0.GeneratedColumn<String> get libraryId =>
$composableBuilder(column: $table.libraryId, builder: (column) => column); $composableBuilder(column: $table.libraryId, builder: (column) => column);
i0.GeneratedColumn<bool> get isEdited =>
$composableBuilder(column: $table.isEdited, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId { i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder( final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -497,6 +512,7 @@ class $$RemoteAssetEntityTableTableManager
const i0.Value.absent(), const i0.Value.absent(),
i0.Value<String?> stackId = const i0.Value.absent(), i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(), i0.Value<String?> libraryId = const i0.Value.absent(),
i0.Value<bool> isEdited = const i0.Value.absent(),
}) => i1.RemoteAssetEntityCompanion( }) => i1.RemoteAssetEntityCompanion(
name: name, name: name,
type: type, type: type,
@@ -516,6 +532,7 @@ class $$RemoteAssetEntityTableTableManager
visibility: visibility, visibility: visibility,
stackId: stackId, stackId: stackId,
libraryId: libraryId, libraryId: libraryId,
isEdited: isEdited,
), ),
createCompanionCallback: createCompanionCallback:
({ ({
@@ -537,6 +554,7 @@ class $$RemoteAssetEntityTableTableManager
required i2.AssetVisibility visibility, required i2.AssetVisibility visibility,
i0.Value<String?> stackId = const i0.Value.absent(), i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(), i0.Value<String?> libraryId = const i0.Value.absent(),
i0.Value<bool> isEdited = const i0.Value.absent(),
}) => i1.RemoteAssetEntityCompanion.insert( }) => i1.RemoteAssetEntityCompanion.insert(
name: name, name: name,
type: type, type: type,
@@ -556,6 +574,7 @@ class $$RemoteAssetEntityTableTableManager
visibility: visibility, visibility: visibility,
stackId: stackId, stackId: stackId,
libraryId: libraryId, libraryId: libraryId,
isEdited: isEdited,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map( .map(
@@ -844,6 +863,21 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
type: i0.DriftSqlType.string, type: i0.DriftSqlType.string,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const i0.VerificationMeta _isEditedMeta = const i0.VerificationMeta(
'isEdited',
);
@override
late final i0.GeneratedColumn<bool> isEdited = i0.GeneratedColumn<bool>(
'is_edited',
aliasedName,
false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_edited" IN (0, 1))',
),
defaultValue: const i4.Constant(false),
);
@override @override
List<i0.GeneratedColumn> get $columns => [ List<i0.GeneratedColumn> get $columns => [
name, name,
@@ -864,6 +898,7 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
visibility, visibility,
stackId, stackId,
libraryId, libraryId,
isEdited,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -987,6 +1022,12 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
libraryId.isAcceptableOrUnknown(data['library_id']!, _libraryIdMeta), libraryId.isAcceptableOrUnknown(data['library_id']!, _libraryIdMeta),
); );
} }
if (data.containsKey('is_edited')) {
context.handle(
_isEditedMeta,
isEdited.isAcceptableOrUnknown(data['is_edited']!, _isEditedMeta),
);
}
return context; return context;
} }
@@ -1075,6 +1116,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}library_id'], data['${effectivePrefix}library_id'],
), ),
isEdited: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_edited'],
)!,
); );
} }
@@ -1115,6 +1160,7 @@ class RemoteAssetEntityData extends i0.DataClass
final i2.AssetVisibility visibility; final i2.AssetVisibility visibility;
final String? stackId; final String? stackId;
final String? libraryId; final String? libraryId;
final bool isEdited;
const RemoteAssetEntityData({ const RemoteAssetEntityData({
required this.name, required this.name,
required this.type, required this.type,
@@ -1134,6 +1180,7 @@ class RemoteAssetEntityData extends i0.DataClass
required this.visibility, required this.visibility,
this.stackId, this.stackId,
this.libraryId, this.libraryId,
required this.isEdited,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -1182,6 +1229,7 @@ class RemoteAssetEntityData extends i0.DataClass
if (!nullToAbsent || libraryId != null) { if (!nullToAbsent || libraryId != null) {
map['library_id'] = i0.Variable<String>(libraryId); map['library_id'] = i0.Variable<String>(libraryId);
} }
map['is_edited'] = i0.Variable<bool>(isEdited);
return map; return map;
} }
@@ -1213,6 +1261,7 @@ class RemoteAssetEntityData extends i0.DataClass
), ),
stackId: serializer.fromJson<String?>(json['stackId']), stackId: serializer.fromJson<String?>(json['stackId']),
libraryId: serializer.fromJson<String?>(json['libraryId']), libraryId: serializer.fromJson<String?>(json['libraryId']),
isEdited: serializer.fromJson<bool>(json['isEdited']),
); );
} }
@override @override
@@ -1241,6 +1290,7 @@ class RemoteAssetEntityData extends i0.DataClass
), ),
'stackId': serializer.toJson<String?>(stackId), 'stackId': serializer.toJson<String?>(stackId),
'libraryId': serializer.toJson<String?>(libraryId), 'libraryId': serializer.toJson<String?>(libraryId),
'isEdited': serializer.toJson<bool>(isEdited),
}; };
} }
@@ -1263,6 +1313,7 @@ class RemoteAssetEntityData extends i0.DataClass
i2.AssetVisibility? visibility, i2.AssetVisibility? visibility,
i0.Value<String?> stackId = const i0.Value.absent(), i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(), i0.Value<String?> libraryId = const i0.Value.absent(),
bool? isEdited,
}) => i1.RemoteAssetEntityData( }) => i1.RemoteAssetEntityData(
name: name ?? this.name, name: name ?? this.name,
type: type ?? this.type, type: type ?? this.type,
@@ -1288,6 +1339,7 @@ class RemoteAssetEntityData extends i0.DataClass
visibility: visibility ?? this.visibility, visibility: visibility ?? this.visibility,
stackId: stackId.present ? stackId.value : this.stackId, stackId: stackId.present ? stackId.value : this.stackId,
libraryId: libraryId.present ? libraryId.value : this.libraryId, libraryId: libraryId.present ? libraryId.value : this.libraryId,
isEdited: isEdited ?? this.isEdited,
); );
RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) { RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) {
return RemoteAssetEntityData( return RemoteAssetEntityData(
@@ -1319,6 +1371,7 @@ class RemoteAssetEntityData extends i0.DataClass
: this.visibility, : this.visibility,
stackId: data.stackId.present ? data.stackId.value : this.stackId, stackId: data.stackId.present ? data.stackId.value : this.stackId,
libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId, libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId,
isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited,
); );
} }
@@ -1342,7 +1395,8 @@ class RemoteAssetEntityData extends i0.DataClass
..write('livePhotoVideoId: $livePhotoVideoId, ') ..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ') ..write('visibility: $visibility, ')
..write('stackId: $stackId, ') ..write('stackId: $stackId, ')
..write('libraryId: $libraryId') ..write('libraryId: $libraryId, ')
..write('isEdited: $isEdited')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -1367,6 +1421,7 @@ class RemoteAssetEntityData extends i0.DataClass
visibility, visibility,
stackId, stackId,
libraryId, libraryId,
isEdited,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -1389,7 +1444,8 @@ class RemoteAssetEntityData extends i0.DataClass
other.livePhotoVideoId == this.livePhotoVideoId && other.livePhotoVideoId == this.livePhotoVideoId &&
other.visibility == this.visibility && other.visibility == this.visibility &&
other.stackId == this.stackId && other.stackId == this.stackId &&
other.libraryId == this.libraryId); other.libraryId == this.libraryId &&
other.isEdited == this.isEdited);
} }
class RemoteAssetEntityCompanion class RemoteAssetEntityCompanion
@@ -1412,6 +1468,7 @@ class RemoteAssetEntityCompanion
final i0.Value<i2.AssetVisibility> visibility; final i0.Value<i2.AssetVisibility> visibility;
final i0.Value<String?> stackId; final i0.Value<String?> stackId;
final i0.Value<String?> libraryId; final i0.Value<String?> libraryId;
final i0.Value<bool> isEdited;
const RemoteAssetEntityCompanion({ const RemoteAssetEntityCompanion({
this.name = const i0.Value.absent(), this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(), this.type = const i0.Value.absent(),
@@ -1431,6 +1488,7 @@ class RemoteAssetEntityCompanion
this.visibility = const i0.Value.absent(), this.visibility = const i0.Value.absent(),
this.stackId = const i0.Value.absent(), this.stackId = const i0.Value.absent(),
this.libraryId = const i0.Value.absent(), this.libraryId = const i0.Value.absent(),
this.isEdited = const i0.Value.absent(),
}); });
RemoteAssetEntityCompanion.insert({ RemoteAssetEntityCompanion.insert({
required String name, required String name,
@@ -1451,6 +1509,7 @@ class RemoteAssetEntityCompanion
required i2.AssetVisibility visibility, required i2.AssetVisibility visibility,
this.stackId = const i0.Value.absent(), this.stackId = const i0.Value.absent(),
this.libraryId = const i0.Value.absent(), this.libraryId = const i0.Value.absent(),
this.isEdited = const i0.Value.absent(),
}) : name = i0.Value(name), }) : name = i0.Value(name),
type = i0.Value(type), type = i0.Value(type),
id = i0.Value(id), id = i0.Value(id),
@@ -1476,6 +1535,7 @@ class RemoteAssetEntityCompanion
i0.Expression<int>? visibility, i0.Expression<int>? visibility,
i0.Expression<String>? stackId, i0.Expression<String>? stackId,
i0.Expression<String>? libraryId, i0.Expression<String>? libraryId,
i0.Expression<bool>? isEdited,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (name != null) 'name': name, if (name != null) 'name': name,
@@ -1496,6 +1556,7 @@ class RemoteAssetEntityCompanion
if (visibility != null) 'visibility': visibility, if (visibility != null) 'visibility': visibility,
if (stackId != null) 'stack_id': stackId, if (stackId != null) 'stack_id': stackId,
if (libraryId != null) 'library_id': libraryId, if (libraryId != null) 'library_id': libraryId,
if (isEdited != null) 'is_edited': isEdited,
}); });
} }
@@ -1518,6 +1579,7 @@ class RemoteAssetEntityCompanion
i0.Value<i2.AssetVisibility>? visibility, i0.Value<i2.AssetVisibility>? visibility,
i0.Value<String?>? stackId, i0.Value<String?>? stackId,
i0.Value<String?>? libraryId, i0.Value<String?>? libraryId,
i0.Value<bool>? isEdited,
}) { }) {
return i1.RemoteAssetEntityCompanion( return i1.RemoteAssetEntityCompanion(
name: name ?? this.name, name: name ?? this.name,
@@ -1538,6 +1600,7 @@ class RemoteAssetEntityCompanion
visibility: visibility ?? this.visibility, visibility: visibility ?? this.visibility,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
libraryId: libraryId ?? this.libraryId, libraryId: libraryId ?? this.libraryId,
isEdited: isEdited ?? this.isEdited,
); );
} }
@@ -1602,6 +1665,9 @@ class RemoteAssetEntityCompanion
if (libraryId.present) { if (libraryId.present) {
map['library_id'] = i0.Variable<String>(libraryId.value); map['library_id'] = i0.Variable<String>(libraryId.value);
} }
if (isEdited.present) {
map['is_edited'] = i0.Variable<bool>(isEdited.value);
}
return map; return map;
} }
@@ -1625,7 +1691,8 @@ class RemoteAssetEntityCompanion
..write('livePhotoVideoId: $livePhotoVideoId, ') ..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ') ..write('visibility: $visibility, ')
..write('stackId: $stackId, ') ..write('stackId: $stackId, ')
..write('libraryId: $libraryId') ..write('libraryId: $libraryId, ')
..write('isEdited: $isEdited')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -45,5 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
height: height, height: height,
width: width, width: width,
orientation: orientation, orientation: orientation,
isEdited: false,
); );
} }
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 16; int get schemaVersion => 17;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -201,6 +201,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v16.idxLocalAssetCloudId); await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity); await m.createTable(v16.remoteAssetCloudIdEntity);
}, },
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
), ),
); );
@@ -6911,6 +6911,503 @@ i1.GeneratedColumn<DateTime> _column_100(String aliasedName) =>
true, true,
type: i1.DriftSqlType.dateTime, type: i1.DriftSqlType.dateTime,
); );
final class Schema17 extends i0.VersionedSchema {
Schema17({required super.database}) : super(version: 17);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape28 extends i0.VersionedTable {
Shape28({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get localDateTime =>
columnsByName['local_date_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get thumbHash =>
columnsByName['thumb_hash']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get livePhotoVideoId =>
columnsByName['live_photo_video_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stackId =>
columnsByName['stack_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get libraryId =>
columnsByName['library_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isEdited =>
columnsByName['is_edited']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_edited',
aliasedName,
false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_edited" IN (0, 1))',
),
defaultValue: const CustomExpression('0'),
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -6927,6 +7424,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15, required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16, required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -7005,6 +7503,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema); await from15To16(migrator, schema);
return 16; return 16;
case 16:
final schema = Schema17(database: database);
final migrator = i1.Migrator(database, schema);
await from16To17(migrator, schema);
return 17;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -7027,6 +7530,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15, required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16, required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -7044,5 +7548,6 @@ i1.OnUpgrade stepByStep({
from13To14: from13To14, from13To14: from13To14,
from14To15: from14To15, from14To15: from14To15,
from15To16: from15To16, from15To16: from15To16,
from16To17: from16To17,
), ),
); );
@@ -200,6 +200,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
libraryId: Value(asset.libraryId), libraryId: Value(asset.libraryId),
width: Value(asset.width), width: Value(asset.width),
height: Value(asset.height), height: Value(asset.height),
isEdited: Value(asset.isEdited),
); );
batch.insert( batch.insert(
@@ -70,6 +70,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
durationInSeconds: row.durationInSeconds, durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId, livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId, stackId: row.stackId,
isEdited: row.isEdited,
) )
: LocalAsset( : LocalAsset(
id: row.localId!, id: row.localId!,
@@ -88,6 +89,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
latitude: row.latitude, latitude: row.latitude,
longitude: row.longitude, longitude: row.longitude,
adjustmentTime: row.adjustmentTime, adjustmentTime: row.adjustmentTime,
isEdited: row.isEdited,
), ),
) )
.get(); .get();
+2 -2
View File
@@ -92,7 +92,7 @@ class _MobileLayout extends StatelessWidget {
], ],
) )
.toList(); .toList();
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]); return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
} }
} }
@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
context.locale; context.locale;
return Scaffold( return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()), appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: section.widget, body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
); );
} }
} }
@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -49,6 +50,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final accessToken = Store.tryGet(StoreKey.accessToken); final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) { if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier); final backupProvider = ref.read(driftBackupProvider.notifier);
@@ -58,6 +60,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async { (_) async {
try { try {
wsProvider.connect(); wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false; bool syncSuccess = false;
@@ -18,6 +18,7 @@ class SyncStatusPage extends StatelessWidget {
splashRadius: 24, splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded), icon: const Icon(Icons.arrow_back_ios_rounded),
), ),
centerTitle: false,
), ),
body: const SyncStatusAndActions(), body: const SyncStatusAndActions(),
); );
@@ -118,6 +118,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
), ),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), _PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId), _PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]); ]);
} }
@@ -167,7 +167,7 @@ class _PlaceTile extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 80, width: 80,
height: 80, height: 80,
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover), child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""),
), ),
), ),
); );
@@ -14,14 +14,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -310,18 +311,17 @@ class _SortButtonState extends ConsumerState<_SortButton> {
: const Icon(Icons.abc, color: Colors.transparent), : const Icon(Icons.abc, color: Colors.transparent),
onPressed: () => onMenuTapped(sortMode), onPressed: () => onMenuTapped(sortMode),
style: ButtonStyle( style: ButtonStyle(
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(16, 16, 32, 16)), padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(12, 12, 24, 12)),
backgroundColor: WidgetStateProperty.all( backgroundColor: WidgetStateProperty.all(
albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent, albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent,
), ),
shape: WidgetStateProperty.all( shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))), const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
), ),
), ),
child: Text( child: Text(
sortMode.label.t(context: context), sortMode.label.t(context: context),
style: context.textTheme.titleSmall?.copyWith( style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: albumSortOption == sortMode color: albumSortOption == sortMode
? context.colorScheme.onPrimary ? context.colorScheme.onPrimary
: context.colorScheme.onSurface.withAlpha(185), : context.colorScheme.onSurface.withAlpha(185),
@@ -344,15 +344,12 @@ class _SortButtonState extends ConsumerState<_SortButton> {
Padding( Padding(
padding: const EdgeInsets.only(right: 5), padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse child: albumSortIsReverse
? const Icon(Icons.keyboard_arrow_down) ? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: const Icon(Icons.keyboard_arrow_up_rounded), : Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
), ),
Text( Text(
albumSortOption.label.t(context: context), albumSortOption.label.t(context: context),
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(225)),
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
),
), ),
isSorting isSorting
? SizedBox( ? SizedBox(
@@ -542,7 +539,11 @@ class _QuickSortAndViewMode extends StatelessWidget {
initialIsReverse: currentIsReverse, initialIsReverse: currentIsReverse,
), ),
IconButton( IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), icon: Icon(
isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined,
size: 24,
color: context.colorScheme.onSurface,
),
onPressed: onToggleViewMode, onPressed: onToggleViewMode,
), ),
], ],
@@ -662,6 +663,8 @@ class _GridAlbumCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector( return GestureDetector(
onTap: () => onAlbumSelected(album), onTap: () => onAlbumSelected(album),
child: Card( child: Card(
@@ -680,12 +683,22 @@ class _GridAlbumCard extends ConsumerWidget {
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)), borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: album.thumbnailAssetId != null child: FutureBuilder(
? Thumbnail.remote(remoteId: album.thumbnailAssetId!) future: albumThumbnailAsset,
: Container( builder: (context, snapshot) {
color: context.colorScheme.surfaceContainerHighest, if (snapshot.hasData && snapshot.data != null) {
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey), return Thumbnail.remote(
), remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
);
}
return Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
);
},
),
), ),
), ),
), ),
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class AlbumTile extends StatelessWidget { class AlbumTile extends ConsumerWidget {
const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected}); const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected});
final RemoteAlbum album; final RemoteAlbum album;
@@ -14,7 +16,9 @@ class AlbumTile extends StatelessWidget {
final Function(RemoteAlbum)? onAlbumSelected; final Function(RemoteAlbum)? onAlbumSelected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return LargeLeadingTile( return LargeLeadingTile(
title: Text( title: Text(
album.name, album.name,
@@ -29,23 +33,35 @@ class AlbumTile extends StatelessWidget {
), ),
onTap: () => onAlbumSelected?.call(album), onTap: () => onAlbumSelected?.call(album),
leadingPadding: const EdgeInsets.only(right: 16), leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null leading: FutureBuilder(
? ClipRRect( future: albumThumbnailAsset,
borderRadius: const BorderRadius.all(Radius.circular(15)), builder: (context, snapshot) {
child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), return snapshot.hasData && snapshot.data != null
) ? ClipRRect(
: SizedBox( borderRadius: const BorderRadius.all(Radius.circular(15)),
width: 80, child: SizedBox(
height: 80, width: 80,
child: Container( height: 80,
decoration: BoxDecoration( child: Thumbnail.remote(
color: context.colorScheme.surfaceContainer, remoteId: album.thumbnailAssetId!,
borderRadius: const BorderRadius.all(Radius.circular(16)), thumbhash: snapshot.data!.thumbHash ?? "",
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1), ),
), ),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), )
), : SizedBox(
), width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
);
},
),
); );
} }
} }
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -164,11 +165,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [ children: [
if (albums.isNotEmpty) if (albums.isNotEmpty)
SheetTile( SheetTile(
title: 'appears_in'.t(context: context).toUpperCase(), title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith( titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(left: 24), padding: const EdgeInsets.only(left: 24),
@@ -224,9 +222,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color, color: context.textTheme.labelLarge?.color,
), ),
subtitle: _getFileInfo(asset, exifInfo), subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith( subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
); );
}, },
); );
@@ -241,9 +237,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color, color: context.textTheme.labelLarge?.color,
), ),
subtitle: _getFileInfo(asset, exifInfo), subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith( subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
); );
} }
} }
@@ -262,11 +256,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(), const SheetLocationDetails(),
// Details header // Details header
SheetTile( SheetTile(
title: 'details'.t(context: context).toUpperCase(), title: 'details'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith( titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
), ),
// File info // File info
buildFileInfoTile(), buildFileInfoTile(),
@@ -278,9 +269,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge, titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo), subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith( subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
), ),
], ],
// Lens info // Lens info
@@ -291,15 +280,13 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge, titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo), subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith( subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
), ),
], ],
// Appears in (Albums) // Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off // padding at the bottom to avoid cut-off
const SizedBox(height: 30), const SizedBox(height: 60),
], ],
); );
} }
@@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -77,11 +78,8 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SheetTile( SheetTile(
title: 'location'.t(context: context).toUpperCase(), title: 'location'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith( titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null, trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation, onTap: editLocation,
), ),
@@ -105,9 +103,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
), ),
Text( Text(
coordinates, coordinates,
style: context.textTheme.labelMedium?.copyWith( style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
), ),
], ],
), ),
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -53,11 +54,8 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
Padding( Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16), padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text( child: Text(
"people".t(context: context).toUpperCase(), "people".t(context: context),
style: context.textTheme.labelMedium?.copyWith( style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
), ),
), ),
SizedBox( SizedBox(
@@ -112,14 +112,17 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else { } else {
final String assetId; final String assetId;
final String thumbhash;
if (asset is LocalAsset && asset.hasRemote) { if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!; assetId = asset.remoteId!;
thumbhash = "";
} else if (asset is RemoteAsset) { } else if (asset is RemoteAsset) {
assetId = asset.id; assetId = asset.id;
thumbhash = asset.thumbHash ?? "";
} else { } else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
} }
provider = RemoteFullImageProvider(assetId: assetId); provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
} }
return provider; return provider;
@@ -132,8 +135,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
} }
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
} }
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;
@@ -16,8 +16,9 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> { with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager(); static final cacheManager = RemoteThumbnailCacheManager();
final String assetId; final String assetId;
final String thumbhash;
RemoteThumbProvider({required this.assetId}); RemoteThumbProvider({required this.assetId, required this.thumbhash});
@override @override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) { Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -38,7 +39,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest( final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId), uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(), headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager, cacheManager: cacheManager,
); );
@@ -49,22 +50,23 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is RemoteThumbProvider) { if (other is RemoteThumbProvider) {
return assetId == other.assetId; return assetId == other.assetId && thumbhash == other.thumbhash;
} }
return false; return false;
} }
@override @override
int get hashCode => assetId.hashCode; int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
} }
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider> class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> { with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager(); static final cacheManager = RemoteThumbnailCacheManager();
final String assetId; final String assetId;
final String thumbhash;
RemoteFullImageProvider({required this.assetId}); RemoteFullImageProvider({required this.assetId, required this.thumbhash});
@override @override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -75,7 +77,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)), initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId), DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -94,7 +96,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest( final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview), uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers, headers: headers,
cacheManager: cacheManager, cacheManager: cacheManager,
); );
@@ -115,12 +117,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) { if (other is RemoteFullImageProvider) {
return assetId == other.assetId; return assetId == other.assetId && thumbhash == other.thumbhash;
} }
return false; return false;
} }
@override @override
int get hashCode => assetId.hashCode; int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
} }
@@ -21,9 +21,14 @@ class Thumbnail extends StatefulWidget {
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key}); const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key}) Thumbnail.remote({
: imageProvider = RemoteThumbProvider(assetId: remoteId), required String remoteId,
thumbhashProvider = null; required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
Thumbnail.fromAsset({ Thumbnail.fromAsset({
required BaseAsset? asset, required BaseAsset? asset,
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -47,6 +48,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final asset = widget.asset; final asset = widget.asset;
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final isCurrentAsset = ref.watch(assetViewerProvider.select((current) => current.currentAsset == asset));
final assetContainerColor = context.isDarkTheme final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4) ? context.primaryColor.darken(amount: 0.4)
@@ -59,6 +61,10 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
final bool storageIndicator = final bool storageIndicator =
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator; ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
if (!isCurrentAsset) {
_hideIndicators = false;
}
if (isSelected) { if (isSelected) {
_showSelectionContainer = true; _showSelectionContainer = true;
} }
@@ -96,7 +102,11 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
children: [ children: [
Positioned.fill( Positioned.fill(
child: Hero( child: Hero(
tag: '${asset?.heroTag ?? ''}_$heroIndex', // This key resets the hero animation when the asset is changed in the asset viewer.
// It doesn't seem like the best solution, and only works to reset the hero, not prime the hero of the new active asset for animation,
// but other solutions have failed thus far.
key: ValueKey(isCurrentAsset),
tag: '${asset?.heroTag}_$heroIndex',
child: Thumbnail.fromAsset(asset: asset, size: widget.size), child: Thumbnail.fromAsset(asset: asset, size: widget.size),
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree. // Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
placeholderBuilder: (context, heroSize, child) { placeholderBuilder: (context, heroSize, child) {
@@ -60,7 +60,11 @@ class DriftMemoryCard extends ConsumerWidget {
child: SizedBox( child: SizedBox(
width: 205, width: 205,
height: 200, height: 200,
child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover), child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
), ),
), ),
Positioned( Positioned(
@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup(); _resumeBackup();
}), }),
_resumeBackup(), _resumeBackup(),
backgroundManager.syncCloudIds(), _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]); ]);
} else { } else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets"); await _safeRun(backgroundManager.hashAssets(), "hashAssets");
+1
View File
@@ -69,6 +69,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
: AssetType.other, : AssetType.other,
createdAt: asset.fileCreatedAt, createdAt: asset.fileCreatedAt,
updatedAt: asset.updatedAt, updatedAt: asset.updatedAt,
isEdited: false,
); );
_gCastService.loadMedia(remoteAsset, reload); _gCastService.loadMedia(remoteAsset, reload);
@@ -144,6 +144,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_hidden', _handleOnAssetHidden); socket.on('on_asset_hidden', _handleOnAssetHidden);
} else { } else {
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
} }
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
@@ -192,10 +193,12 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void stopListeningToBetaEvents() { void stopListeningToBetaEvents() {
state.socket?.off('AssetUploadReadyV1'); state.socket?.off('AssetUploadReadyV1');
state.socket?.off('AssetEditReadyV1');
} }
void startListeningToBetaEvents() { void startListeningToBetaEvents() {
state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
state.socket?.on('AssetEditReadyV1', _handleSyncAssetEditReady);
} }
void listenUploadEvent() { void listenUploadEvent() {
@@ -315,6 +318,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchDebouncer.run(_processBatchedAssetUploadReady); _batchDebouncer.run(_processBatchedAssetUploadReady);
} }
void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data]));
}
void _processBatchedAssetUploadReady() { void _processBatchedAssetUploadReady() {
if (_batchedAssetUploadReady.isEmpty) { if (_batchedAssetUploadReady.isEmpty) {
return; return;
@@ -25,6 +25,7 @@ class FileMediaRepository {
type: AssetType.image, type: AssetType.image,
createdAt: entity.createDateTime, createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime, updatedAt: entity.modifiedDateTime,
isEdited: false,
); );
} }
@@ -307,10 +307,10 @@ class BackgroundUploadService {
priority: priority, priority: priority,
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi, requiresWiFi: requiresWiFi,
cloudId: asset.cloudId, cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(), adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(), latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
longitude: asset.longitude?.toString(), longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
); );
} }
@@ -16,6 +16,7 @@ import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
@@ -40,6 +41,7 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider), ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider),
); );
}); });
@@ -55,6 +57,7 @@ class ForegroundUploadService {
this._backupRepository, this._backupRepository,
this._connectivityApi, this._connectivityApi,
this._appSettingsService, this._appSettingsService,
this._assetMediaRepository,
); );
final UploadRepository _uploadRepository; final UploadRepository _uploadRepository;
@@ -62,6 +65,7 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi; final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('ForegroundUploadService'); final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false; bool shouldAbortUpload = false;
@@ -311,7 +315,8 @@ class ForegroundUploadService {
return; return;
} }
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
@@ -322,19 +327,6 @@ class ForegroundUploadService {
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(), 'isFavorite': asset.isFavorite.toString(),
'duration': asset.duration.toString(), 'duration': asset.duration.toString(),
if (CurrentPlatform.isIOS && asset.cloudId != null)
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]),
}; };
// Upload live photo video first if available // Upload live photo video first if available
@@ -363,6 +355,22 @@ class ForegroundUploadService {
fields['livePhotoVideoId'] = livePhotoVideoId; fields['livePhotoVideoId'] = livePhotoVideoId;
} }
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]);
}
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,
+6 -1
View File
@@ -61,7 +61,12 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
), ),
), ),
chipTheme: const ChipThemeData(side: BorderSide.none), chipTheme: const ChipThemeData(side: BorderSide.none),
sliderTheme: const SliderThemeData(thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), trackHeight: 2.0), sliderTheme: const SliderThemeData(
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
trackHeight: 2.0,
// ignore: deprecated_member_use
year2023: false,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed),
popupMenuTheme: const PopupMenuThemeData( popupMenuTheme: const PopupMenuThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
+3 -1
View File
@@ -50,8 +50,10 @@ String getThumbnailUrlForRemoteId(
final String id, { final String id, {
AssetMediaSize type = AssetMediaSize.thumbnail, AssetMediaSize type = AssetMediaSize.thumbnail,
bool edited = true, bool edited = true,
String? thumbhash,
}) { }) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited'; final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
} }
String getPlaybackUrlForRemoteId(final String id) { String getPlaybackUrlForRemoteId(final String id) {
+5
View File
@@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
if (value is Map) { if (value is Map) {
addDefault(value, 'visibility', 'timeline'); addDefault(value, 'visibility', 'timeline');
addDefault(value, 'createdAt', DateTime.now().toIso8601String()); addDefault(value, 'createdAt', DateTime.now().toIso8601String());
addDefault(value, 'isEdited', false);
} }
break; break;
case 'UserAdminResponseDto': case 'UserAdminResponseDto':
@@ -46,6 +47,10 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
addDefault(value, 'hasProfileImage', false); addDefault(value, 'hasProfileImage', false);
} }
case 'SyncAssetV1':
if (value is Map) {
addDefault(value, 'isEdited', false);
}
case 'ServerFeaturesDto': case 'ServerFeaturesDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'ocr', false); addDefault(value, 'ocr', false);
@@ -33,7 +33,7 @@ class PersonNameEditForm extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'name'.tr(), hintText: 'name'.tr(),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: isError.value ? 'Error occured' : null, errorText: isError.value ? 'Error occurred' : null,
), ),
), ),
), ),
@@ -1,14 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
class GroupSettings extends HookConsumerWidget { class GroupSettings extends HookConsumerWidget {
const GroupSettings({super.key}); const GroupSettings({super.key});
@@ -33,12 +33,24 @@ class GroupSettings extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "asset_list_group_by_sub_title".tr()), SettingGroupTitle(
title: "asset_list_group_by_sub_title".t(context: context),
icon: Icons.group_work_outlined,
),
SettingsRadioListTile( SettingsRadioListTile(
groups: [ groups: [
SettingsRadioGroup(title: 'asset_list_layout_settings_group_by_month_day'.tr(), value: GroupAssetsBy.day), SettingsRadioGroup(
SettingsRadioGroup(title: 'month'.tr(), value: GroupAssetsBy.month), title: 'asset_list_layout_settings_group_by_month_day'.t(context: context),
SettingsRadioGroup(title: 'asset_list_layout_settings_group_automatically'.tr(), value: GroupAssetsBy.auto), value: GroupAssetsBy.day,
),
SettingsRadioGroup(
title: 'month'.t(context: context),
value: GroupAssetsBy.month,
),
SettingsRadioGroup(
title: 'asset_list_layout_settings_group_automatically'.t(context: context),
value: GroupAssetsBy.auto,
),
], ],
groupBy: groupBy, groupBy: groupBy,
onRadioChanged: changeGroupValue, onRadioChanged: changeGroupValue,
@@ -1,11 +1,12 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class LayoutSettings extends HookConsumerWidget { class LayoutSettings extends HookConsumerWidget {
@@ -19,10 +20,13 @@ class LayoutSettings extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "asset_list_layout_sub_title".tr()), SettingGroupTitle(
title: "asset_list_layout_sub_title".t(context: context),
icon: Icons.view_module_outlined,
),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useDynamicLayout, valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".tr(), title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
SettingsSliderListTile( SettingsSliderListTile(
@@ -1,10 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -19,21 +18,21 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "setting_image_viewer_title".tr()), SettingGroupTitle(
ListTile( title: "photos".t(context: context),
contentPadding: const EdgeInsets.symmetric(horizontal: 20), icon: Icons.image_outlined,
title: Text('setting_image_viewer_help', style: context.textTheme.bodyMedium).tr(), subtitle: "setting_image_viewer_help".t(context: context),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: isPreview, valueNotifier: isPreview,
title: "setting_image_viewer_preview_title".tr(), title: "setting_image_viewer_preview_title".t(context: context),
subtitle: "setting_image_viewer_preview_subtitle".tr(), subtitle: "setting_image_viewer_preview_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: isOriginal, valueNotifier: isOriginal,
title: "setting_image_viewer_original_title".tr(), title: "setting_image_viewer_original_title".t(context: context),
subtitle: "setting_image_viewer_original_subtitle".tr(), subtitle: "setting_image_viewer_original_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
], ],
@@ -1,9 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -19,23 +19,26 @@ class VideoViewerSettings extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "videos".tr()), SettingGroupTitle(
title: "videos".t(context: context),
icon: Icons.video_camera_back_outlined,
),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useAutoPlayVideo, valueNotifier: useAutoPlayVideo,
title: "setting_video_viewer_auto_play_title".tr(), title: "setting_video_viewer_auto_play_title".t(context: context),
subtitle: "setting_video_viewer_auto_play_subtitle".tr(), subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useLoopVideo, valueNotifier: useLoopVideo,
title: "setting_video_viewer_looping_title".tr(), title: "setting_video_viewer_looping_title".t(context: context),
subtitle: "loop_videos_description".tr(), subtitle: "loop_videos_description".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useOriginalVideo, valueNotifier: useOriginalVideo,
title: "setting_video_viewer_original_video_title".tr(), title: "setting_video_viewer_original_video_title".t(context: context),
subtitle: "setting_video_viewer_original_video_subtitle".tr(), subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider), onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
), ),
], ],
@@ -16,6 +16,8 @@ import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class DriftBackupSettings extends ConsumerWidget { class DriftBackupSettings extends ConsumerWidget {
@@ -25,36 +27,25 @@ class DriftBackupSettings extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return SettingsSubPageScaffold( return SettingsSubPageScaffold(
settings: [ settings: [
Padding( SettingGroupTitle(
padding: const EdgeInsets.only(left: 16.0), title: "network_requirements".t(context: context),
child: Text( icon: Icons.cell_tower,
"network_requirements".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
),
), ),
const _UseWifiForUploadVideosButton(), const _UseWifiForUploadVideosButton(),
const _UseWifiForUploadPhotosButton(), const _UseWifiForUploadPhotosButton(),
if (CurrentPlatform.isAndroid) ...[ if (CurrentPlatform.isAndroid) ...[
const Divider(), const Divider(),
Padding( SettingGroupTitle(
padding: const EdgeInsets.only(left: 16.0), title: "background_options".t(context: context),
child: Text( icon: Icons.charging_station_rounded,
"background_options".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
), ),
const _BackupOnlyWhenChargingButton(), const _BackupOnlyWhenChargingButton(),
const _BackupDelaySlider(), const _BackupDelaySlider(),
], ],
const Divider(), const Divider(),
Padding( SettingGroupTitle(
padding: const EdgeInsets.only(left: 16.0), title: "backup_albums_sync".t(context: context),
child: Text( icon: Icons.sync,
"backup_albums_sync".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
),
), ),
const _AlbumSyncActionButton(), const _AlbumSyncActionButton(),
], ],
@@ -105,81 +96,67 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return Padding(
shrinkWrap: true, padding: const EdgeInsets.only(left: 8.0),
children: [ child: ListView(
StreamBuilder( shrinkWrap: true,
stream: Store.watch(StoreKey.syncAlbums), children: [
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false, StreamBuilder(
builder: (context, snapshot) { stream: Store.watch(StoreKey.syncAlbums),
final albumSyncEnable = snapshot.data ?? false; initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
return Column( builder: (context, snapshot) {
children: [ final albumSyncEnable = snapshot.data ?? false;
ListTile( return Column(
title: Text( children: [
"sync_albums".t(context: context), SettingListTile(
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), title: "sync_albums".t(context: context),
), subtitle: "sync_upload_album_setting_subtitle".t(context: context),
subtitle: Text( trailing: Switch(
"sync_upload_album_setting_subtitle".t(context: context), value: albumSyncEnable,
style: context.textTheme.labelLarge, onChanged: (bool newValue) async {
), await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
if (newValue == true) { if (newValue == true) {
await _manageLinkedAlbums(); await _manageLinkedAlbums();
} }
}, },
),
), ),
), AnimatedSize(
AnimatedSize( duration: const Duration(milliseconds: 300),
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut,
curve: Curves.easeInOut, child: AnimatedOpacity(
child: AnimatedOpacity( duration: const Duration(milliseconds: 200),
duration: const Duration(milliseconds: 200), opacity: albumSyncEnable ? 1.0 : 0.0,
opacity: albumSyncEnable ? 1.0 : 0.0, child: albumSyncEnable
child: albumSyncEnable ? SettingListTile(
? ListTile( onTap: _manualSyncAlbums,
onTap: _manualSyncAlbums, contentPadding: const EdgeInsets.only(left: 32, right: 16),
contentPadding: const EdgeInsets.only(left: 32, right: 16), title: "organize_into_albums".t(context: context),
title: Text( subtitle: "organize_into_albums_description".t(context: context),
"organize_into_albums".t(context: context), trailing: isAlbumSyncInProgress
style: context.textTheme.titleSmall?.copyWith( ? const SizedBox(
color: context.colorScheme.onSurface, width: 32,
fontWeight: FontWeight.normal, height: 32,
), child: CircularProgressIndicator.adaptive(strokeWidth: 2),
), )
subtitle: Text( : IconButton(
"organize_into_albums_description".t(context: context), onPressed: _manualSyncAlbums,
style: context.textTheme.bodyMedium?.copyWith( icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7), color: context.colorScheme.onSurface.withValues(alpha: 0.7),
), iconSize: 20,
), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
trailing: isAlbumSyncInProgress ),
? const SizedBox( )
width: 32, : const SizedBox.shrink(),
height: 32, ),
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: IconButton(
onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
)
: const SizedBox.shrink(),
), ),
), ],
], );
); },
}, ),
), ],
], ),
); );
} }
} }
@@ -222,24 +199,24 @@ class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return Padding(
title: Text( padding: const EdgeInsets.only(left: 8.0),
widget.titleKey.t(context: context), child: SettingListTile(
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), title: widget.titleKey.t(context: context),
), subtitle: widget.subtitleKey.t(context: context),
subtitle: Text(widget.subtitleKey.t(context: context), style: context.textTheme.labelLarge), trailing: StreamBuilder(
trailing: StreamBuilder( stream: valueStream,
stream: valueStream, initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue, builder: (context, snapshot) {
builder: (context, snapshot) { final value = snapshot.data ?? false;
final value = snapshot.data ?? false; return Switch(
return Switch( value: value,
value: value, onChanged: (bool newValue) async {
onChanged: (bool newValue) async { await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue); },
}, );
); },
}, ),
), ),
); );
} }
@@ -354,7 +331,7 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
'backup_controller_page_background_delay'.tr( 'backup_controller_page_background_delay'.tr(
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)}, namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
), ),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
), ),
), ),
Slider( Slider(
@@ -34,33 +34,36 @@ class EntityCountTile extends StatelessWidget {
children: [ children: [
// Icon and Label // Icon and Label
Row( Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, color: context.primaryColor), Icon(icon, color: context.primaryColor, size: 14),
const SizedBox(width: 8), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16), style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w500),
), ),
), ),
], ],
), ),
// Number // Number
const Spacer(), const Spacer(),
RichText( Padding(
text: TextSpan( padding: const EdgeInsets.only(top: 8.0),
style: const TextStyle(fontSize: 18, fontFamily: 'GoogleSansCode', fontWeight: FontWeight.w600), child: RichText(
children: [ text: TextSpan(
TextSpan( style: const TextStyle(fontSize: 18, fontFamily: 'GoogleSansCode'),
text: zeroPadding(count, maxDigits), children: [
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)), TextSpan(
), text: zeroPadding(count, maxDigits),
TextSpan( style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
text: count.toString(), ),
style: TextStyle(color: context.primaryColor), TextSpan(
), text: count.toString(),
], style: TextStyle(color: context.colorScheme.onSurface),
),
],
),
), ),
), ),
], ],
@@ -16,6 +16,8 @@ import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'
import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -112,48 +114,39 @@ class SyncStatusAndActions extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 16, bottom: 96), padding: const EdgeInsets.only(top: 16, bottom: 96),
children: [ children: [
const _SyncStatsCounts(), const _SyncStatsCounts(),
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 10),
const SizedBox(height: 24), const SizedBox(height: 16),
_SectionHeaderText(text: "jobs".t(context: context)), SettingGroupTitle(title: "jobs".t(context: context)),
ListTile( SettingListTile(
title: Text( title: "sync_local".t(context: context),
"sync_local".t(context: context), subtitle: "tap_to_run_job".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.sync), leading: const Icon(Icons.sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
onTap: () { onTap: () {
ref.read(backgroundSyncProvider).syncLocal(full: true); ref.read(backgroundSyncProvider).syncLocal(full: true);
}, },
), ),
ListTile( SettingListTile(
title: Text( title: "sync_remote".t(context: context),
"sync_remote".t(context: context), subtitle: "tap_to_run_job".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.cloud_sync), leading: const Icon(Icons.cloud_sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
onTap: () { onTap: () {
ref.read(backgroundSyncProvider).syncRemote(); ref.read(backgroundSyncProvider).syncRemote();
}, },
), ),
ListTile( SettingListTile(
title: Text( title: "hash_asset".t(context: context),
"hash_asset".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
leading: const Icon(Icons.tag), leading: const Icon(Icons.tag),
subtitle: Text("tap_to_run_job".t(context: context)), subtitle: "tap_to_run_job".t(context: context),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
onTap: () { onTap: () {
ref.read(backgroundSyncProvider).hashAssets(); ref.read(backgroundSyncProvider).hashAssets();
}, },
), ),
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1),
const SizedBox(height: 24), const SizedBox(height: 16),
_SectionHeaderText(text: "actions".t(context: context)), SettingGroupTitle(title: "actions".t(context: context)),
ListTile( ListTile(
title: Text( title: Text(
"clear_file_cache".t(context: context), "clear_file_cache".t(context: context),
@@ -202,26 +195,6 @@ class _SyncStatusIcon extends StatelessWidget {
} }
} }
class _SectionHeaderText extends StatelessWidget {
final String text;
const _SectionHeaderText({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
text.toUpperCase(),
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(200),
),
),
);
}
}
class _SyncStatsCounts extends ConsumerWidget { class _SyncStatsCounts extends ConsumerWidget {
const _SyncStatsCounts(); const _SyncStatsCounts();
@@ -279,9 +252,9 @@ class _SyncStatsCounts extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SectionHeaderText(text: "assets".t(context: context)), SettingGroupTitle(title: "assets".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
// 1. Wrap in IntrinsicHeight // 1. Wrap in IntrinsicHeight
child: IntrinsicHeight( child: IntrinsicHeight(
child: Flex( child: Flex(
@@ -309,9 +282,9 @@ class _SyncStatsCounts extends ConsumerWidget {
), ),
), ),
), ),
_SectionHeaderText(text: "albums".t(context: context)), SettingGroupTitle(title: "albums".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: IntrinsicHeight( child: IntrinsicHeight(
child: Flex( child: Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
@@ -337,9 +310,9 @@ class _SyncStatsCounts extends ConsumerWidget {
), ),
), ),
), ),
_SectionHeaderText(text: "other".t(context: context)), SettingGroupTitle(title: "other".t(context: context)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: IntrinsicHeight( child: IntrinsicHeight(
child: Flex( child: Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
@@ -368,7 +341,7 @@ class _SyncStatsCounts extends ConsumerWidget {
// To be removed once the experimental feature is stable // To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid && if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[ appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
_SectionHeaderText(text: "trash".t(context: context)), SettingGroupTitle(title: "trash".t(context: context)),
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final counts = ref.watch(trashedAssetsCountProvider); final counts = ref.watch(trashedAssetsCountProvider);
@@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
class BetaTimelineListTile extends ConsumerWidget { class BetaTimelineListTile extends ConsumerWidget {
const BetaTimelineListTile({super.key}); const BetaTimelineListTile({super.key});
@@ -56,8 +57,8 @@ class BetaTimelineListTile extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 4.0), padding: const EdgeInsets.only(left: 4.0),
child: ListTile( child: SettingListTile(
title: Text("new_timeline".t(context: context)), title: "new_timeline".t(context: context),
trailing: Switch.adaptive( trailing: Switch.adaptive(
value: betaTimelineValue, value: betaTimelineValue,
onChanged: onSwitchChanged, onChanged: onSwitchChanged,
@@ -142,7 +142,9 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
final state = ref.watch(cleanupProvider); final state = ref.watch(cleanupProvider);
final hasDate = state.selectedDate != null; final hasDate = state.selectedDate != null;
final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty; final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty;
final subtitleStyle = context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
);
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) { StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
switch (stepState) { switch (stepState) {
case StepState.complete: case StepState.complete:
@@ -214,10 +216,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)), border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)),
), ),
child: Text( child: Text('free_up_space_description'.t(context: context), style: context.textTheme.bodyMedium),
'free_up_space_description'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
), ),
), ),
@@ -256,7 +255,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge), Text('cutoff_date_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 16), const SizedBox(height: 16),
GridView.count( GridView.count(
shrinkWrap: true, shrinkWrap: true,
@@ -352,7 +351,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('cleanup_filter_description'.t(context: context), style: context.textTheme.labelLarge), Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 16), const SizedBox(height: 16),
SegmentedButton<AssetFilterType>( SegmentedButton<AssetFilterType>(
segments: [ segments: [
@@ -381,10 +380,15 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
const SizedBox(height: 16), const SizedBox(height: 16),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text('keep_favorites'.t(context: context), style: context.textTheme.titleSmall), title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
subtitle: Text( subtitle: Text(
'keep_favorites_description'.t(context: context), 'keep_favorites_description'.t(context: context),
style: context.textTheme.labelLarge, style: context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
),
), ),
value: state.keepFavorites, value: state.keepFavorites,
onChanged: (value) { onChanged: (value) {
@@ -435,10 +439,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
: null, : null,
content: Column( content: Column(
children: [ children: [
Text( Text('cleanup_step3_description'.t(context: context), style: subtitleStyle),
'cleanup_step3_description'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
if (CurrentPlatform.isIOS) ...[ if (CurrentPlatform.isIOS) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@@ -117,7 +117,7 @@ class EndpointInputState extends ConsumerState<EndpointInput> {
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateUrl, validator: validateUrl,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
style: const TextStyle(fontFamily: 'GoogleSansCode', fontWeight: FontWeight.w600, fontSize: 14), style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 14),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'http(s)://immich.domain.com', hintText: 'http(s)://immich.domain.com',
contentPadding: const EdgeInsets.all(16), contentPadding: const EdgeInsets.all(16),
@@ -1,12 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
@@ -103,7 +103,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 24), padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 24),
child: Text("external_network_sheet_info".tr(), style: context.textTheme.bodyMedium), child: Text("external_network_sheet_info".t(context: context), style: context.textTheme.bodyMedium),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Divider(color: context.colorScheme.surfaceContainerHighest), Divider(color: context.colorScheme.surfaceContainerHighest),
@@ -135,7 +135,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
height: 48, height: 48,
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text('add_endpoint'.tr().toUpperCase()), label: Text('add_endpoint'.t(context: context)),
onPressed: enabled onPressed: enabled
? () { ? () {
entries.value = [ entries.value = [
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_mobile/providers/network.provider.dart';
@@ -167,13 +168,12 @@ class LocalNetworkPreference extends HookConsumerWidget {
enabled: enabled, enabled: enabled,
contentPadding: const EdgeInsets.only(left: 24, right: 8), contentPadding: const EdgeInsets.only(left: 24, right: 8),
leading: const Icon(Icons.lan_rounded), leading: const Icon(Icons.lan_rounded),
title: Text("server_endpoint".tr()), title: Text("server_endpoint".t(context: context)),
subtitle: localEndpointText.value.isEmpty subtitle: localEndpointText.value.isEmpty
? const Text("http://local-ip:2283") ? const Text("http://local-ip:2283")
: Text( : Text(
localEndpointText.value, localEndpointText.value,
style: context.textTheme.labelLarge?.copyWith( style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: enabled ? context.primaryColor : context.colorScheme.onSurface.withAlpha(100), color: enabled ? context.primaryColor : context.colorScheme.onSurface.withAlpha(100),
fontFamily: 'GoogleSansCode', fontFamily: 'GoogleSansCode',
), ),
@@ -190,7 +190,7 @@ class LocalNetworkPreference extends HookConsumerWidget {
height: 48, height: 48,
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.wifi_find_rounded), icon: const Icon(Icons.wifi_find_rounded),
label: Text('use_current_connection'.tr().toUpperCase()), label: Text('use_current_connection'.t(context: context)),
onPressed: enabled ? autofillCurrentNetwork : null, onPressed: enabled ? autofillCurrentNetwork : null,
), ),
), ),
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_mobile/providers/network.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
@@ -10,6 +11,7 @@ import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class NetworkingSettings extends HookConsumerWidget { class NetworkingSettings extends HookConsumerWidget {
@@ -87,12 +89,10 @@ class NetworkingSettings extends HookConsumerWidget {
return ListView( return ListView(
padding: const EdgeInsets.only(bottom: 96), padding: const EdgeInsets.only(bottom: 96),
children: <Widget>[ children: <Widget>[
Padding( const SizedBox(height: 8),
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), SettingGroupTitle(
child: NetworkPreferenceTitle( title: "current_server_address".t(context: context),
title: "current_server_address".tr().toUpperCase(), icon: (currentEndpoint?.startsWith('https') ?? false) ? Icons.https_outlined : Icons.http_outlined,
icon: (currentEndpoint?.startsWith('https') ?? false) ? Icons.https_outlined : Icons.http_outlined,
),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -108,12 +108,7 @@ class NetworkingSettings extends HookConsumerWidget {
: const Icon(Icons.circle_outlined), : const Icon(Icons.circle_outlined),
title: Text( title: Text(
currentEndpoint ?? "--", currentEndpoint ?? "--",
style: TextStyle( style: TextStyle(fontSize: 14, fontFamily: 'GoogleSansCode', color: context.primaryColor),
fontSize: 16,
fontFamily: 'GoogleSansCode',
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
), ),
), ),
), ),
@@ -128,14 +123,16 @@ class NetworkingSettings extends HookConsumerWidget {
title: "automatic_endpoint_switching_title".tr(), title: "automatic_endpoint_switching_title".tr(),
subtitle: "automatic_endpoint_switching_subtitle".tr(), subtitle: "automatic_endpoint_switching_subtitle".tr(),
), ),
Padding( const SizedBox(height: 8),
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 16), SettingGroupTitle(
child: NetworkPreferenceTitle(title: "local_network".tr().toUpperCase(), icon: Icons.home_outlined), title: "local_network".t(context: context),
icon: Icons.home_outlined,
), ),
LocalNetworkPreference(enabled: featureEnabled.value), LocalNetworkPreference(enabled: featureEnabled.value),
Padding( const SizedBox(height: 16),
padding: const EdgeInsets.only(top: 32, left: 16, bottom: 16), SettingGroupTitle(
child: NetworkPreferenceTitle(title: "external_network".tr().toUpperCase(), icon: Icons.dns_outlined), title: "external_network".t(context: context),
icon: Icons.dns_outlined,
), ),
ExternalNetworkPreference(enabled: featureEnabled.value), ExternalNetworkPreference(enabled: featureEnabled.value),
], ],
@@ -143,30 +140,6 @@ class NetworkingSettings extends HookConsumerWidget {
} }
} }
class NetworkPreferenceTitle extends StatelessWidget {
const NetworkPreferenceTitle({super.key, required this.icon, required this.title});
final IconData icon;
final String title;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: context.colorScheme.onSurface.withAlpha(150)),
const SizedBox(width: 8),
Text(
title,
style: context.textTheme.displaySmall?.copyWith(
color: context.colorScheme.onSurface.withAlpha(200),
fontWeight: FontWeight.w500,
),
),
],
);
}
}
class NetworkStatusIcon extends StatelessWidget { class NetworkStatusIcon extends StatelessWidget {
const NetworkStatusIcon({super.key, required this.status, this.enabled = true}) : super(); const NetworkStatusIcon({super.key, required this.status, this.enabled = true}) : super();
@@ -175,10 +148,10 @@ class NetworkStatusIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedSwitcher(duration: const Duration(milliseconds: 200), child: _buildIcon(context)); return AnimatedSwitcher(duration: const Duration(milliseconds: 200), child: buildIcon(context));
} }
Widget _buildIcon(BuildContext context) => switch (status) { Widget buildIcon(BuildContext context) => switch (status) {
AuxCheckStatus.loading => Padding( AuxCheckStatus.loading => Padding(
padding: const EdgeInsets.only(left: 4.0), padding: const EdgeInsets.only(left: 4.0),
child: SizedBox( child: SizedBox(
@@ -1,9 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -22,10 +22,13 @@ class HapticSetting extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "haptic_feedback_title".tr()), SettingGroupTitle(
title: "haptic_feedback_title".t(context: context),
icon: Icons.vibration_outlined,
),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: isHapticFeedbackEnabled, valueNotifier: isHapticFeedbackEnabled,
title: 'haptic_feedback_switch'.tr(), title: 'enabled'.t(context: context),
onChanged: onHapticFeedbackChange, onChanged: onHapticFeedbackChange,
), ),
], ],
@@ -1,12 +1,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -74,23 +74,26 @@ class ThemeSetting extends HookConsumerWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "theme".tr()), SettingGroupTitle(
title: "theme".t(context: context),
icon: Icons.color_lens_outlined,
),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: isSystemTheme, valueNotifier: isSystemTheme,
title: 'theme_setting_system_theme_switch'.tr(), title: 'theme_setting_system_theme_switch'.t(context: context),
onChanged: onSystemThemeChange, onChanged: onSystemThemeChange,
), ),
if (currentTheme.value != ThemeMode.system) if (currentTheme.value != ThemeMode.system)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: isDarkTheme, valueNotifier: isDarkTheme,
title: 'map_settings_dark_mode'.tr(), title: 'map_settings_dark_mode'.t(context: context),
onChanged: onThemeChange, onChanged: onThemeChange,
), ),
const PrimaryColorSetting(), const PrimaryColorSetting(),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: applyThemeToBackgroundProvider, valueNotifier: applyThemeToBackgroundProvider,
title: "theme_setting_colorful_interface_title".tr(), title: "theme_setting_colorful_interface_title".t(context: context),
subtitle: 'theme_setting_colorful_interface_subtitle'.tr(), subtitle: 'theme_setting_colorful_interface_subtitle'.t(context: context),
onChanged: onSurfaceColorSettingChange, onChanged: onSurfaceColorSettingChange,
), ),
], ],
@@ -0,0 +1,39 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingGroupTitle extends StatelessWidget {
final String title;
final String? subtitle;
final IconData? icon;
final EdgeInsetsGeometry? contentPadding;
const SettingGroupTitle({super.key, required this.title, this.icon, this.subtitle, this.contentPadding});
@override
Widget build(BuildContext context) {
return Padding(
padding: contentPadding ?? const EdgeInsets.only(left: 20.0, right: 20.0, bottom: 8.0),
child: Column(
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, color: context.colorScheme.onSurfaceSecondary, size: 20),
const SizedBox(width: 8),
],
Text(title, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary)),
],
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: context.textTheme.bodyMedium!.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
),
],
],
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SettingListTile extends StatelessWidget {
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final EdgeInsetsGeometry? contentPadding;
const SettingListTile({
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.contentPadding,
super.key,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
subtitle: subtitle != null
? Text(
subtitle!,
style: context.textTheme.bodyMedium!.copyWith(color: context.textTheme.bodyMedium!.color!.withAlpha(215)),
)
: null,
leading: leading,
trailing: trailing,
onTap: onTap,
contentPadding: contentPadding,
);
}
}
@@ -36,11 +36,8 @@ class SettingsCard extends StatelessWidget {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Icon(icon, color: context.primaryColor), child: Icon(icon, color: context.primaryColor),
), ),
title: Text( title: Text(title, style: context.textTheme.titleMedium!.copyWith(color: context.primaryColor)),
title, subtitle: Text(subtitle, style: context.textTheme.bodyMedium),
style: context.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
),
subtitle: Text(subtitle, style: context.textTheme.labelLarge),
onTap: () => context.pushRoute(settingRoute), onTap: () => context.pushRoute(settingRoute),
), ),
), ),
@@ -9,13 +9,11 @@ class SettingsSubPageScaffold extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 16),
itemCount: settings.length, itemCount: settings.length,
itemBuilder: (ctx, index) => settings[index], itemBuilder: (ctx, index) => settings[index],
separatorBuilder: (context, index) => showDivider separatorBuilder: (context, index) => showDivider
? const Column( ? const Column(children: [SizedBox(height: 5), Divider(height: 10), SizedBox(height: 15)])
children: [SizedBox(height: 5), Divider(height: 10, indent: 15, endIndent: 15), SizedBox(height: 15)],
)
: const SizedBox(height: 10), : const SizedBox(height: 10),
); );
} }
+269
View File
@@ -0,0 +1,269 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class DatabaseBackupsAdminApi {
DatabaseBackupsAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Delete database backup
///
/// Delete a backup by its filename
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
Future<Response> deleteDatabaseBackupWithHttpInfo(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups';
// ignore: prefer_final_locals
Object? postBody = databaseBackupDeleteDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Delete database backup
///
/// Delete a backup by its filename
///
/// Parameters:
///
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
Future<void> deleteDatabaseBackup(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
final response = await deleteDatabaseBackupWithHttpInfo(databaseBackupDeleteDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Download database backup
///
/// Downloads the database backup file
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] filename (required):
Future<Response> downloadDatabaseBackupWithHttpInfo(String filename,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups/{filename}'
.replaceAll('{filename}', filename);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Download database backup
///
/// Downloads the database backup file
///
/// Parameters:
///
/// * [String] filename (required):
Future<MultipartFile?> downloadDatabaseBackup(String filename,) async {
final response = await downloadDatabaseBackupWithHttpInfo(filename,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// List database backups
///
/// Get the list of the successful and failed backups
///
/// Note: This method returns the HTTP [Response].
Future<Response> listDatabaseBackupsWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List database backups
///
/// Get the list of the successful and failed backups
Future<DatabaseBackupListResponseDto?> listDatabaseBackups() async {
final response = await listDatabaseBackupsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DatabaseBackupListResponseDto',) as DatabaseBackupListResponseDto;
}
return null;
}
/// Start database backup restore flow
///
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
///
/// Note: This method returns the HTTP [Response].
Future<Response> startDatabaseRestoreFlowWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups/start-restore';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Start database backup restore flow
///
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
Future<void> startDatabaseRestoreFlow() async {
final response = await startDatabaseRestoreFlowWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Upload database backup
///
/// Uploads .sql/.sql.gz file to restore backup from
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [MultipartFile] file:
Future<Response> uploadDatabaseBackupWithHttpInfo({ MultipartFile? file, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/database-backups/upload';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('POST', Uri.parse(apiPath));
if (file != null) {
hasFields = true;
mp.fields[r'file'] = file.field;
mp.files.add(file);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Upload database backup
///
/// Uploads .sql/.sql.gz file to restore backup from
///
/// Parameters:
///
/// * [MultipartFile] file:
Future<void> uploadDatabaseBackup({ MultipartFile? file, }) async {
final response = await uploadDatabaseBackupWithHttpInfo( file: file, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}
+9 -1
View File
@@ -26,6 +26,7 @@ class AssetResponseDto {
required this.height, required this.height,
required this.id, required this.id,
required this.isArchived, required this.isArchived,
required this.isEdited,
required this.isFavorite, required this.isFavorite,
required this.isOffline, required this.isOffline,
required this.isTrashed, required this.isTrashed,
@@ -85,6 +86,8 @@ class AssetResponseDto {
bool isArchived; bool isArchived;
bool isEdited;
bool isFavorite; bool isFavorite;
bool isOffline; bool isOffline;
@@ -162,6 +165,7 @@ class AssetResponseDto {
other.height == height && other.height == height &&
other.id == id && other.id == id &&
other.isArchived == isArchived && other.isArchived == isArchived &&
other.isEdited == isEdited &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.isOffline == isOffline && other.isOffline == isOffline &&
other.isTrashed == isTrashed && other.isTrashed == isTrashed &&
@@ -200,6 +204,7 @@ class AssetResponseDto {
(height == null ? 0 : height!.hashCode) + (height == null ? 0 : height!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isArchived.hashCode) + (isArchived.hashCode) +
(isEdited.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(isOffline.hashCode) + (isOffline.hashCode) +
(isTrashed.hashCode) + (isTrashed.hashCode) +
@@ -223,7 +228,7 @@ class AssetResponseDto {
(width == null ? 0 : width!.hashCode); (width == null ? 0 : width!.hashCode);
@override @override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -252,6 +257,7 @@ class AssetResponseDto {
} }
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isArchived'] = this.isArchived; json[r'isArchived'] = this.isArchived;
json[r'isEdited'] = this.isEdited;
json[r'isFavorite'] = this.isFavorite; json[r'isFavorite'] = this.isFavorite;
json[r'isOffline'] = this.isOffline; json[r'isOffline'] = this.isOffline;
json[r'isTrashed'] = this.isTrashed; json[r'isTrashed'] = this.isTrashed;
@@ -332,6 +338,7 @@ class AssetResponseDto {
: num.parse('${json[r'height']}'), : num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!, isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!, isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isOffline: mapValueOfType<bool>(json, r'isOffline')!, isOffline: mapValueOfType<bool>(json, r'isOffline')!,
isTrashed: mapValueOfType<bool>(json, r'isTrashed')!, isTrashed: mapValueOfType<bool>(json, r'isTrashed')!,
@@ -413,6 +420,7 @@ class AssetResponseDto {
'height', 'height',
'id', 'id',
'isArchived', 'isArchived',
'isEdited',
'isFavorite', 'isFavorite',
'isOffline', 'isOffline',
'isTrashed', 'isTrashed',
+101
View File
@@ -0,0 +1,101 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class DatabaseBackupDeleteDto {
/// Returns a new [DatabaseBackupDeleteDto] instance.
DatabaseBackupDeleteDto({
this.backups = const [],
});
List<String> backups;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDeleteDto &&
_deepEquality.equals(other.backups, backups);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(backups.hashCode);
@override
String toString() => 'DatabaseBackupDeleteDto[backups=$backups]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'backups'] = this.backups;
return json;
}
/// Returns a new [DatabaseBackupDeleteDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DatabaseBackupDeleteDto? fromJson(dynamic value) {
upgradeDto(value, "DatabaseBackupDeleteDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return DatabaseBackupDeleteDto(
backups: json[r'backups'] is Iterable
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<DatabaseBackupDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DatabaseBackupDeleteDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DatabaseBackupDeleteDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DatabaseBackupDeleteDto> mapFromJson(dynamic json) {
final map = <String, DatabaseBackupDeleteDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DatabaseBackupDeleteDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DatabaseBackupDeleteDto-objects as value to a dart map
static Map<String, List<DatabaseBackupDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DatabaseBackupDeleteDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DatabaseBackupDeleteDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'backups',
};
}
+107
View File
@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class DatabaseBackupDto {
/// Returns a new [DatabaseBackupDto] instance.
DatabaseBackupDto({
required this.filename,
required this.filesize,
});
String filename;
num filesize;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
other.filename == filename &&
other.filesize == filesize;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filename.hashCode) +
(filesize.hashCode);
@override
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'filename'] = this.filename;
json[r'filesize'] = this.filesize;
return json;
}
/// Returns a new [DatabaseBackupDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DatabaseBackupDto? fromJson(dynamic value) {
upgradeDto(value, "DatabaseBackupDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
);
}
return null;
}
static List<DatabaseBackupDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DatabaseBackupDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DatabaseBackupDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DatabaseBackupDto> mapFromJson(dynamic json) {
final map = <String, DatabaseBackupDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DatabaseBackupDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DatabaseBackupDto-objects as value to a dart map
static Map<String, List<DatabaseBackupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DatabaseBackupDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DatabaseBackupDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'filename',
'filesize',
};
}
@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class DatabaseBackupListResponseDto {
/// Returns a new [DatabaseBackupListResponseDto] instance.
DatabaseBackupListResponseDto({
this.backups = const [],
});
List<DatabaseBackupDto> backups;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupListResponseDto &&
_deepEquality.equals(other.backups, backups);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(backups.hashCode);
@override
String toString() => 'DatabaseBackupListResponseDto[backups=$backups]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'backups'] = this.backups;
return json;
}
/// Returns a new [DatabaseBackupListResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DatabaseBackupListResponseDto? fromJson(dynamic value) {
upgradeDto(value, "DatabaseBackupListResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return DatabaseBackupListResponseDto(
backups: DatabaseBackupDto.listFromJson(json[r'backups']),
);
}
return null;
}
static List<DatabaseBackupListResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DatabaseBackupListResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DatabaseBackupListResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DatabaseBackupListResponseDto> mapFromJson(dynamic json) {
final map = <String, DatabaseBackupListResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DatabaseBackupListResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DatabaseBackupListResponseDto-objects as value to a dart map
static Map<String, List<DatabaseBackupListResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DatabaseBackupListResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DatabaseBackupListResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'backups',
};
}
+6
View File
@@ -25,11 +25,15 @@ class MaintenanceAction {
static const start = MaintenanceAction._(r'start'); static const start = MaintenanceAction._(r'start');
static const end = MaintenanceAction._(r'end'); static const end = MaintenanceAction._(r'end');
static const selectDatabaseRestore = MaintenanceAction._(r'select_database_restore');
static const restoreDatabase = MaintenanceAction._(r'restore_database');
/// List of all possible values in this [enum][MaintenanceAction]. /// List of all possible values in this [enum][MaintenanceAction].
static const values = <MaintenanceAction>[ static const values = <MaintenanceAction>[
start, start,
end, end,
selectDatabaseRestore,
restoreDatabase,
]; ];
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value); static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
@@ -70,6 +74,8 @@ class MaintenanceActionTypeTransformer {
switch (data) { switch (data) {
case r'start': return MaintenanceAction.start; case r'start': return MaintenanceAction.start;
case r'end': return MaintenanceAction.end; case r'end': return MaintenanceAction.end;
case r'select_database_restore': return MaintenanceAction.selectDatabaseRestore;
case r'restore_database': return MaintenanceAction.restoreDatabase;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceDetectInstallResponseDto {
/// Returns a new [MaintenanceDetectInstallResponseDto] instance.
MaintenanceDetectInstallResponseDto({
this.storage = const [],
});
List<MaintenanceDetectInstallStorageFolderDto> storage;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceDetectInstallResponseDto &&
_deepEquality.equals(other.storage, storage);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(storage.hashCode);
@override
String toString() => 'MaintenanceDetectInstallResponseDto[storage=$storage]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'storage'] = this.storage;
return json;
}
/// Returns a new [MaintenanceDetectInstallResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceDetectInstallResponseDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceDetectInstallResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallResponseDto(
storage: MaintenanceDetectInstallStorageFolderDto.listFromJson(json[r'storage']),
);
}
return null;
}
static List<MaintenanceDetectInstallResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceDetectInstallResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceDetectInstallResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceDetectInstallResponseDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceDetectInstallResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceDetectInstallResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceDetectInstallResponseDto-objects as value to a dart map
static Map<String, List<MaintenanceDetectInstallResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceDetectInstallResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceDetectInstallResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'storage',
};
}
@@ -0,0 +1,123 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceDetectInstallStorageFolderDto {
/// Returns a new [MaintenanceDetectInstallStorageFolderDto] instance.
MaintenanceDetectInstallStorageFolderDto({
required this.files,
required this.folder,
required this.readable,
required this.writable,
});
num files;
StorageFolder folder;
bool readable;
bool writable;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceDetectInstallStorageFolderDto &&
other.files == files &&
other.folder == folder &&
other.readable == readable &&
other.writable == writable;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(files.hashCode) +
(folder.hashCode) +
(readable.hashCode) +
(writable.hashCode);
@override
String toString() => 'MaintenanceDetectInstallStorageFolderDto[files=$files, folder=$folder, readable=$readable, writable=$writable]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'files'] = this.files;
json[r'folder'] = this.folder;
json[r'readable'] = this.readable;
json[r'writable'] = this.writable;
return json;
}
/// Returns a new [MaintenanceDetectInstallStorageFolderDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceDetectInstallStorageFolderDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceDetectInstallStorageFolderDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto(
files: num.parse('${json[r'files']}'),
folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!,
);
}
return null;
}
static List<MaintenanceDetectInstallStorageFolderDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceDetectInstallStorageFolderDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceDetectInstallStorageFolderDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceDetectInstallStorageFolderDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceDetectInstallStorageFolderDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceDetectInstallStorageFolderDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceDetectInstallStorageFolderDto-objects as value to a dart map
static Map<String, List<MaintenanceDetectInstallStorageFolderDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceDetectInstallStorageFolderDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceDetectInstallStorageFolderDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'files',
'folder',
'readable',
'writable',
};
}
@@ -0,0 +1,158 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceStatusResponseDto {
/// Returns a new [MaintenanceStatusResponseDto] instance.
MaintenanceStatusResponseDto({
required this.action,
required this.active,
this.error,
this.progress,
this.task,
});
MaintenanceAction action;
bool active;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? error;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? progress;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? task;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceStatusResponseDto &&
other.action == action &&
other.active == active &&
other.error == error &&
other.progress == progress &&
other.task == task;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(active.hashCode) +
(error == null ? 0 : error!.hashCode) +
(progress == null ? 0 : progress!.hashCode) +
(task == null ? 0 : task!.hashCode);
@override
String toString() => 'MaintenanceStatusResponseDto[action=$action, active=$active, error=$error, progress=$progress, task=$task]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'active'] = this.active;
if (this.error != null) {
json[r'error'] = this.error;
} else {
// json[r'error'] = null;
}
if (this.progress != null) {
json[r'progress'] = this.progress;
} else {
// json[r'progress'] = null;
}
if (this.task != null) {
json[r'task'] = this.task;
} else {
// json[r'task'] = null;
}
return json;
}
/// Returns a new [MaintenanceStatusResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceStatusResponseDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceStatusResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceStatusResponseDto(
action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'),
task: mapValueOfType<String>(json, r'task'),
);
}
return null;
}
static List<MaintenanceStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceStatusResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceStatusResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceStatusResponseDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceStatusResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceStatusResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceStatusResponseDto-objects as value to a dart map
static Map<String, List<MaintenanceStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceStatusResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceStatusResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'active',
};
}
+12
View File
@@ -62,6 +62,10 @@ class Permission {
static const authPeriodChangePassword = Permission._(r'auth.changePassword'); static const authPeriodChangePassword = Permission._(r'auth.changePassword');
static const authDevicePeriodDelete = Permission._(r'authDevice.delete'); static const authDevicePeriodDelete = Permission._(r'authDevice.delete');
static const archivePeriodRead = Permission._(r'archive.read'); static const archivePeriodRead = Permission._(r'archive.read');
static const backupPeriodList = Permission._(r'backup.list');
static const backupPeriodDownload = Permission._(r'backup.download');
static const backupPeriodUpload = Permission._(r'backup.upload');
static const backupPeriodDelete = Permission._(r'backup.delete');
static const duplicatePeriodRead = Permission._(r'duplicate.read'); static const duplicatePeriodRead = Permission._(r'duplicate.read');
static const duplicatePeriodDelete = Permission._(r'duplicate.delete'); static const duplicatePeriodDelete = Permission._(r'duplicate.delete');
static const facePeriodCreate = Permission._(r'face.create'); static const facePeriodCreate = Permission._(r'face.create');
@@ -214,6 +218,10 @@ class Permission {
authPeriodChangePassword, authPeriodChangePassword,
authDevicePeriodDelete, authDevicePeriodDelete,
archivePeriodRead, archivePeriodRead,
backupPeriodList,
backupPeriodDownload,
backupPeriodUpload,
backupPeriodDelete,
duplicatePeriodRead, duplicatePeriodRead,
duplicatePeriodDelete, duplicatePeriodDelete,
facePeriodCreate, facePeriodCreate,
@@ -401,6 +409,10 @@ class PermissionTypeTransformer {
case r'auth.changePassword': return Permission.authPeriodChangePassword; case r'auth.changePassword': return Permission.authPeriodChangePassword;
case r'authDevice.delete': return Permission.authDevicePeriodDelete; case r'authDevice.delete': return Permission.authDevicePeriodDelete;
case r'archive.read': return Permission.archivePeriodRead; case r'archive.read': return Permission.archivePeriodRead;
case r'backup.list': return Permission.backupPeriodList;
case r'backup.download': return Permission.backupPeriodDownload;
case r'backup.upload': return Permission.backupPeriodUpload;
case r'backup.delete': return Permission.backupPeriodDelete;
case r'duplicate.read': return Permission.duplicatePeriodRead; case r'duplicate.read': return Permission.duplicatePeriodRead;
case r'duplicate.delete': return Permission.duplicatePeriodDelete; case r'duplicate.delete': return Permission.duplicatePeriodDelete;
case r'face.create': return Permission.facePeriodCreate; case r'face.create': return Permission.facePeriodCreate;
+20 -3
View File
@@ -14,25 +14,41 @@ class SetMaintenanceModeDto {
/// Returns a new [SetMaintenanceModeDto] instance. /// Returns a new [SetMaintenanceModeDto] instance.
SetMaintenanceModeDto({ SetMaintenanceModeDto({
required this.action, required this.action,
this.restoreBackupFilename,
}); });
MaintenanceAction action; MaintenanceAction action;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? restoreBackupFilename;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto && bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
other.action == action; other.action == action &&
other.restoreBackupFilename == restoreBackupFilename;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(action.hashCode); (action.hashCode) +
(restoreBackupFilename == null ? 0 : restoreBackupFilename!.hashCode);
@override @override
String toString() => 'SetMaintenanceModeDto[action=$action]'; String toString() => 'SetMaintenanceModeDto[action=$action, restoreBackupFilename=$restoreBackupFilename]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'action'] = this.action; json[r'action'] = this.action;
if (this.restoreBackupFilename != null) {
json[r'restoreBackupFilename'] = this.restoreBackupFilename;
} else {
// json[r'restoreBackupFilename'] = null;
}
return json; return json;
} }
@@ -46,6 +62,7 @@ class SetMaintenanceModeDto {
return SetMaintenanceModeDto( return SetMaintenanceModeDto(
action: MaintenanceAction.fromJson(json[r'action'])!, action: MaintenanceAction.fromJson(json[r'action'])!,
restoreBackupFilename: mapValueOfType<String>(json, r'restoreBackupFilename'),
); );
} }
return null; return null;
+97
View File
@@ -0,0 +1,97 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class StorageFolder {
/// Instantiate a new enum with the provided [value].
const StorageFolder._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const encodedVideo = StorageFolder._(r'encoded-video');
static const library_ = StorageFolder._(r'library');
static const upload = StorageFolder._(r'upload');
static const profile = StorageFolder._(r'profile');
static const thumbs = StorageFolder._(r'thumbs');
static const backups = StorageFolder._(r'backups');
/// List of all possible values in this [enum][StorageFolder].
static const values = <StorageFolder>[
encodedVideo,
library_,
upload,
profile,
thumbs,
backups,
];
static StorageFolder? fromJson(dynamic value) => StorageFolderTypeTransformer().decode(value);
static List<StorageFolder> listFromJson(dynamic json, {bool growable = false,}) {
final result = <StorageFolder>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = StorageFolder.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [StorageFolder] to String,
/// and [decode] dynamic data back to [StorageFolder].
class StorageFolderTypeTransformer {
factory StorageFolderTypeTransformer() => _instance ??= const StorageFolderTypeTransformer._();
const StorageFolderTypeTransformer._();
String encode(StorageFolder data) => data.value;
/// Decodes a [dynamic value][data] to a StorageFolder.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
StorageFolder? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'encoded-video': return StorageFolder.encodedVideo;
case r'library': return StorageFolder.library_;
case r'upload': return StorageFolder.upload;
case r'profile': return StorageFolder.profile;
case r'thumbs': return StorageFolder.thumbs;
case r'backups': return StorageFolder.backups;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [StorageFolderTypeTransformer] instance.
static StorageFolderTypeTransformer? _instance;
}
+9 -1
View File
@@ -20,6 +20,7 @@ class SyncAssetV1 {
required this.fileModifiedAt, required this.fileModifiedAt,
required this.height, required this.height,
required this.id, required this.id,
required this.isEdited,
required this.isFavorite, required this.isFavorite,
required this.libraryId, required this.libraryId,
required this.livePhotoVideoId, required this.livePhotoVideoId,
@@ -47,6 +48,8 @@ class SyncAssetV1 {
String id; String id;
bool isEdited;
bool isFavorite; bool isFavorite;
String? libraryId; String? libraryId;
@@ -78,6 +81,7 @@ class SyncAssetV1 {
other.fileModifiedAt == fileModifiedAt && other.fileModifiedAt == fileModifiedAt &&
other.height == height && other.height == height &&
other.id == id && other.id == id &&
other.isEdited == isEdited &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.libraryId == libraryId && other.libraryId == libraryId &&
other.livePhotoVideoId == livePhotoVideoId && other.livePhotoVideoId == livePhotoVideoId &&
@@ -100,6 +104,7 @@ class SyncAssetV1 {
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) + (height == null ? 0 : height!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isEdited.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
@@ -113,7 +118,7 @@ class SyncAssetV1 {
(width == null ? 0 : width!.hashCode); (width == null ? 0 : width!.hashCode);
@override @override
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -144,6 +149,7 @@ class SyncAssetV1 {
// json[r'height'] = null; // json[r'height'] = null;
} }
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isEdited'] = this.isEdited;
json[r'isFavorite'] = this.isFavorite; json[r'isFavorite'] = this.isFavorite;
if (this.libraryId != null) { if (this.libraryId != null) {
json[r'libraryId'] = this.libraryId; json[r'libraryId'] = this.libraryId;
@@ -198,6 +204,7 @@ class SyncAssetV1 {
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
height: mapValueOfType<int>(json, r'height'), height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!, isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'), libraryId: mapValueOfType<String>(json, r'libraryId'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'), livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
@@ -263,6 +270,7 @@ class SyncAssetV1 {
'fileModifiedAt', 'fileModifiedAt',
'height', 'height',
'id', 'id',
'isEdited',
'isFavorite', 'isFavorite',
'libraryId', 'libraryId',
'livePhotoVideoId', 'livePhotoVideoId',
@@ -44,6 +44,7 @@ SyncAssetV1 _createAsset({
livePhotoVideoId: null, livePhotoVideoId: null,
stackId: null, stackId: null,
thumbhash: null, thumbhash: null,
isEdited: false,
); );
} }
+4
View File
@@ -19,6 +19,7 @@ import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14; import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15; import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16; import 'schema_v16.dart' as v16;
import 'schema_v17.dart' as v17;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -56,6 +57,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v15.DatabaseAtV15(db); return v15.DatabaseAtV15(db);
case 16: case 16:
return v16.DatabaseAtV16(db); return v16.DatabaseAtV16(db);
case 17:
return v17.DatabaseAtV17(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -78,5 +81,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
14, 14,
15, 15,
16, 16,
17,
]; ];
} }
File diff suppressed because it is too large Load Diff
+2
View File
@@ -64,6 +64,7 @@ abstract final class LocalAssetStub {
type: AssetType.image, type: AssetType.image,
createdAt: DateTime(2025), createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2), updatedAt: DateTime(2025, 2),
isEdited: false,
); );
static final image2 = LocalAsset( static final image2 = LocalAsset(
@@ -72,5 +73,6 @@ abstract final class LocalAssetStub {
type: AssetType.image, type: AssetType.image,
createdAt: DateTime(2000), createdAt: DateTime(2000),
updatedAt: DateTime(20021), updatedAt: DateTime(20021),
isEdited: false,
); );
} }
+1
View File
@@ -128,6 +128,7 @@ abstract final class SyncStreamStub {
visibility: AssetVisibility.timeline, visibility: AssetVisibility.timeline,
width: null, width: null,
height: null, height: null,
isEdited: false,
), ),
ack: ack, ack: ack,
); );
@@ -194,6 +194,7 @@ void main() {
latitude: 37.7749, latitude: 37.7749,
longitude: -122.4194, longitude: -122.4194,
adjustmentTime: DateTime(2026, 1, 2), adjustmentTime: DateTime(2026, 1, 2),
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
@@ -242,6 +243,7 @@ void main() {
cloudId: 'cloud-id-123', cloudId: 'cloud-id-123',
latitude: 37.7749, latitude: 37.7749,
longitude: -122.4194, longitude: -122.4194,
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
@@ -279,6 +281,7 @@ void main() {
createdAt: DateTime(2025, 1, 1), createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2), updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId cloudId: null, // No cloudId
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
@@ -320,6 +323,7 @@ void main() {
cloudId: 'cloud-id-livephoto', cloudId: 'cloud-id-livephoto',
latitude: 37.7749, latitude: 37.7749,
longitude: -122.4194, longitude: -122.4194,
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
+2
View File
@@ -131,6 +131,7 @@ abstract final class TestUtils {
isFavorite: false, isFavorite: false,
width: width, width: width,
height: height, height: height,
isEdited: false,
); );
} }
@@ -154,6 +155,7 @@ abstract final class TestUtils {
width: width, width: width,
height: height, height: height,
orientation: orientation, orientation: orientation,
isEdited: false,
); );
} }
} }
@@ -27,6 +27,7 @@ class MediumFactory {
type: type ?? AssetType.image, type: type ?? AssetType.image,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
isEdited: false,
); );
} }

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