Compare commits

...

18 Commits

Author SHA1 Message Date
shenlong-tanwen
5ca2c5effc refactor: migrate app metadata 2026-06-17 18:51:01 +05:30
shenlong-tanwen
febc162821 refactor: migrate advance config from store 2026-06-16 23:47:28 +05:30
shenlong-tanwen
a855852ae7 refactor: use currentUser for auth user table 2026-06-16 19:59:35 +05:30
shenlong-tanwen
35fdaaeed8 migrate session config from store 2026-06-11 23:32:35 +05:30
shenlong-tanwen
5f2f472f98 add map codec tests 2026-06-11 18:38:12 +05:30
shenlong-tanwen
e459c3d5a3 rebase over null store key 2026-06-11 17:45:57 +05:30
shenlong-tanwen
800bfab956 extract CachedKeyValueRepository 2026-06-11 17:35:17 +05:30
shenlong-tanwen
84ba31bad4 refactor(mobile): extract shared ValueCodec from SettingsKey
# Conflicts:
#	mobile/lib/domain/models/settings_key.dart
2026-06-11 17:34:40 +05:30
shenlong-tanwen
e225f874b1 refactor: nullable settings key 2026-06-11 14:16:14 +05:30
Santo Shakil
d227ba2d51 fix(mobile): stale details after editing asset date (#28977) 2026-06-10 21:32:02 -05:00
Santo Shakil
9cb94343d1 fix(mobile): keep timezone when editing asset date time (#28978)
* fix(mobile): keep timezone when editing asset date time

* fix(mobile): negative utc offsets with minutes off by an hour
2026-06-10 21:31:31 -05:00
Mert
aa126e377c fix(server): add hint header for segment after init.mp4 (#28867)
* add hint header for segment after init.mp4

* use zod

* actually validate

* update openapi

* linting
2026-06-10 19:18:36 -04:00
Paul Makles
74878628c8 feat: integrity check jobs (missing files, untracked files, checksums) (#24205)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Signed-off-by: izzy <me@insrt.uk>
2026-06-10 21:02:27 +02:00
Mees Frensel
4ead3e697d chore(server): update asset not ready error messages (#28968) 2026-06-10 20:23:17 +02:00
Daniel Dietzler
fb798a8f29 chore: remove person workflow elements (#28974) 2026-06-10 18:49:33 +02:00
Santo Shakil
07813135b5 fix(mobile): deduplicate people in asset details panel (#28972) 2026-06-10 14:37:45 +00:00
Daniel Dietzler
92a75b0cd3 fix(web): person that is in the same asset multiple times (#28971) 2026-06-10 09:32:29 -05:00
Alex
8132e8a38c feat: image quality option in sharing (#28918)
* feat: share with quality options

* merge main

* clean up

* refactor

* translation

* translation

* add settings and default behavior

* fix: lint

* cleanup

* merge main

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-10 09:26:09 -05:00
198 changed files with 52460 additions and 1022 deletions

View File

@@ -99,7 +99,7 @@ describe('/admin/maintenance', () => {
},
{
interval: 500,
timeout: 10_000,
timeout: 60_000,
},
)
.toBeTruthy();
@@ -190,7 +190,7 @@ describe('/admin/maintenance', () => {
},
{
interval: 500,
timeout: 10_000,
timeout: 60_000,
},
)
.toBeFalsy();

View File

@@ -0,0 +1,669 @@
import {
AssetMediaResponseDto,
IntegrityReportResponseDto,
LoginResponseDto,
ManualJobName,
QueueCommand,
QueueName,
} from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const asset1Filepath = `${testAssetDir}/albums/nature/el_torcal_rocks.jpg`;
const asset2Filepath = `${testAssetDir}/albums/nature/wood_anemones.jpg`;
describe('/admin/integrity', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let user1: LoginResponseDto;
let asset1: AssetMediaResponseDto;
let user2: LoginResponseDto;
let asset2: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
user1 = await utils.userSetup(admin.accessToken, {
email: '1@example.com',
name: '1',
password: '1',
});
user2 = await utils.userSetup(admin.accessToken, {
email: '2@example.com',
name: '2',
password: '2',
});
for (const queue of Object.values(QueueName)) {
if (queue === QueueName.IntegrityCheck) {
continue;
}
await utils.queueCommand(admin.accessToken, queue, {
command: QueueCommand.Pause,
});
}
asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename: 'asset.jpg',
bytes: await readFile(assetFilepath),
},
});
asset1 = await utils.createAsset(user1.accessToken, {
assetData: {
filename: 'asset.jpg',
bytes: await readFile(asset1Filepath),
},
});
asset2 = await utils.createAsset(user2.accessToken, {
assetData: {
filename: 'asset.jpg',
bytes: await readFile(asset2Filepath),
},
});
await utils.mkFolder('/data/bak');
await utils.copyFolder(`/data/upload/${admin.userId}`, `/data/bak/${admin.userId}`);
for (const queue of Object.values(QueueName)) {
if (queue === QueueName.IntegrityCheck) {
continue;
}
await utils.queueCommand(admin.accessToken, queue, {
command: QueueCommand.Empty,
});
await utils.queueCommand(admin.accessToken, queue, {
command: QueueCommand.Resume,
});
}
});
afterEach(async () => {
await utils.deleteFolder(`/data/upload/${admin.userId}`);
await utils.copyFolder(`/data/bak/${admin.userId}`, `/data/upload/${admin.userId}`);
});
describe('POST /summary (& jobs)', async () => {
it.sequential('reports no issues', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFilesDeleteAll,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual({
missing_file: 0,
untracked_file: 0,
checksum_mismatch: 0,
});
});
it.sequential('should detect an untracked file (job: check untracked files)', async () => {
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
untracked_file: 1,
}),
);
});
it.sequential('should detect outdated untracked file reports (job: refresh untracked files)', async () => {
// these should not be detected:
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked2.png`);
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked3.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFilesRefresh,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
untracked_file: 0,
}),
);
});
it.sequential('should delete untracked files (job: delete all untracked file reports)', async () => {
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFilesDeleteAll,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
untracked_file: 0,
}),
);
});
it.sequential('should detect a missing file and not a checksum mismatch (job: check missing files)', async () => {
await utils.deleteFolder(`/data/upload/${admin.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
missing_file: 1,
checksum_mismatch: 0,
}),
);
});
it.sequential('should detect outdated missing file reports (job: refresh missing files)', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFilesRefresh,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
missing_file: 0,
checksum_mismatch: 0,
}),
);
});
it.sequential('should delete assets with missing files (job: delete all missing file reports)', async () => {
await utils.deleteFolder(`/data/upload/${user1.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus, body: listBody } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus).toBe(200);
expect(listBody).toEqual(
expect.objectContaining({
missing_file: 1,
}),
);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFilesDeleteAll,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
missing_file: 0,
}),
);
await expect(utils.getAssetInfo(user1.accessToken, asset1.id)).resolves.toEqual(
expect.objectContaining({
isTrashed: true,
}),
);
});
it.sequential('should detect a checksum mismatch (job: check file checksums)', async () => {
await utils.truncateFolder(`/data/upload/${admin.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
checksum_mismatch: 1,
}),
);
});
it.sequential('should detect outdated checksum mismatch reports (job: refresh file checksums)', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatchRefresh,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
checksum_mismatch: 0,
}),
);
});
it.sequential(
'should delete assets with mismatched checksum (job: delete all checksum mismatch reports)',
async () => {
await utils.truncateFolder(`/data/upload/${user2.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus, body: listBody } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus).toBe(200);
expect(listBody).toEqual(
expect.objectContaining({
checksum_mismatch: 1,
}),
);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatchDeleteAll,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
checksum_mismatch: 0,
}),
);
await expect(utils.getAssetInfo(user2.accessToken, asset2.id)).resolves.toEqual(
expect.objectContaining({
isTrashed: true,
}),
);
},
);
});
describe('POST /report', async () => {
it.sequential('reports untracked files', async () => {
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/report?type=untracked_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual({
nextCursor: undefined,
items: expect.arrayContaining([
{
id: expect.any(String),
type: 'untracked_file',
path: `/data/upload/${admin.userId}/untracked1.png`,
assetId: null,
fileAssetId: null,
createdAt: expect.any(String),
},
]),
});
});
it.sequential('reports missing files', async () => {
await utils.deleteFolder(`/data/upload/${admin.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/report?type=missing_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual({
nextCursor: undefined,
items: expect.arrayContaining([
{
id: expect.any(String),
type: 'missing_file',
path: expect.any(String),
assetId: asset.id,
fileAssetId: null,
createdAt: expect.any(String),
},
]),
});
});
it.sequential('reports checksum mismatched files', async () => {
await utils.truncateFolder(`/data/upload/${admin.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app)
.get('/admin/integrity/report?type=checksum_mismatch')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual({
nextCursor: undefined,
items: expect.arrayContaining([
{
id: expect.any(String),
type: 'checksum_mismatch',
path: expect.any(String),
assetId: asset.id,
fileAssetId: null,
createdAt: expect.any(String),
},
]),
});
});
});
describe('DELETE /report/:id', async () => {
it.sequential('delete untracked files', async () => {
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus, body: listBody } = await request(app)
.get('/admin/integrity/report?type=untracked_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus).toBe(200);
const report = (listBody as IntegrityReportResponseDto).items.find(
(item) => item.path === `/data/upload/${admin.userId}/untracked1.png`,
)!;
const { status } = await request(app)
.delete(`/admin/integrity/report/${report.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus2, body: listBody2 } = await request(app)
.get('/admin/integrity/report?type=untracked_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus2).toBe(200);
expect(listBody2).not.toBe(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
id: report.id,
}),
]),
}),
);
});
it.sequential('delete assets missing files', async () => {
await utils.deleteFolder(`/data/upload/${admin.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus, body: listBody } = await request(app)
.get('/admin/integrity/report?type=missing_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus).toBe(200);
expect(listBody.items.length).toBe(1);
const report = (listBody as IntegrityReportResponseDto).items[0];
const { status } = await request(app)
.delete(`/admin/integrity/report/${report.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus2, body: listBody2 } = await request(app)
.get('/admin/integrity/report?type=missing_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus2).toBe(200);
expect(listBody2.items.length).toBe(0);
});
it.sequential('delete assets with failing checksum', async () => {
await utils.truncateFolder(`/data/upload/${admin.userId}`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus, body: listBody } = await request(app)
.get('/admin/integrity/report?type=checksum_mismatch')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus).toBe(200);
expect(listBody.items.length).toBe(1);
const report = (listBody as IntegrityReportResponseDto).items[0];
const { status } = await request(app)
.delete(`/admin/integrity/report/${report.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status: listStatus2, body: listBody2 } = await request(app)
.get('/admin/integrity/report?type=checksum_mismatch')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(listStatus2).toBe(200);
expect(listBody2.items.length).toBe(0);
});
});
describe('GET /report/:type/csv', () => {
it.sequential('exports untracked files as csv', async () => {
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, headers, text } = await request(app)
.get('/admin/integrity/report/untracked_file/csv')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(headers['content-type']).toContain('text/csv');
expect(headers['content-disposition']).toContain('.csv');
expect(text).toContain('id,type,assetId,fileAssetId,path');
expect(text).toContain(`untracked_file`);
expect(text).toContain(`/data/upload/${admin.userId}/untracked1.png`);
});
});
describe('GET /report/:id/file', () => {
it.sequential('downloads untracked file', async () => {
await utils.putTextFile('untracked-content', `/data/upload/${admin.userId}/untracked1.png`);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { body: listBody } = await request(app)
.get('/admin/integrity/report?type=untracked_file')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
const report = (listBody as IntegrityReportResponseDto).items.find(
(item) => item.path === `/data/upload/${admin.userId}/untracked1.png`,
)!;
const { status, headers, body } = await request(app)
.get(`/admin/integrity/report/${report.id}/file`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.buffer(true)
.send();
expect(status).toBe(200);
expect(headers['content-type']).toContain('application/octet-stream');
expect(body.toString()).toBe('untracked-content');
});
});
});

View File

@@ -0,0 +1,41 @@
import { LoginResponseDto, ManualJobName, QueueName } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe.skip('Integrity', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('run integrity jobs to update stats', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityUntrackedFiles,
});
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
await page.goto('/admin/maintenance');
const count = page.getByText('Untracked Files').locator('..').locator('..').locator('div').nth(1);
const previousCount = Number.parseInt((await count.textContent()) ?? '');
await utils.mkFolder(`/data/upload/${admin.userId}`);
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
const checkButton = page.getByText('Integrity Report').locator('..').getByRole('button', { name: 'Check All' });
await checkButton.click();
await expect(checkButton).toBeEnabled();
await expect(count).toContainText((previousCount + 1).toString());
});
});

View File

@@ -192,6 +192,7 @@ export const utils = {
'user',
'system_metadata',
'tag',
'integrity_report',
];
const truncateTables = tables.filter((table) => table !== 'system_metadata');
@@ -559,10 +560,54 @@ export const utils = {
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
},
putFile(source: string, dest: string) {
return executeCommand('docker', ['cp', source, `immich-e2e-server:${dest}`]).promise;
},
async putTextFile(contents: string, dest: string) {
const dir = await mkdtemp(join(tmpdir(), 'test-'));
const fn = join(dir, 'file');
await pipeline(Readable.from(contents), createWriteStream(fn));
return executeCommand('docker', ['cp', fn, `immich-e2e-server:${dest}`]).promise;
},
async move(source: string, dest: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
},
async copyFolder(source: string, dest: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'cp', '-r', source, dest]).promise;
},
async deleteFile(path: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', path]).promise;
},
async deleteFolder(path: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', '-r', path]).promise;
},
async truncateFolder(path: string) {
return executeCommand('docker', [
'exec',
'immich-e2e-server',
'find',
path,
'-type',
'f',
'-exec',
'truncate',
'-s',
'1',
'{}',
';',
]).promise;
},
async mkFolder(path: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mkdir', '-p', path]).promise;
},
createBackup: async (accessToken: string) => {
await utils.createJob(accessToken, {
name: ManualJobName.BackupDatabase,
@@ -579,10 +624,8 @@ export const utils = {
resetBackups: async (accessToken: string) => {
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
const backupFiles = backups.map((b) => b.filename);
await deleteDatabaseBackup(
{ databaseBackupDeleteDto: { backups: backupFiles } },
{ databaseBackupDeleteDto: { backups: backups.map((dto) => dto.filename) } },
{ headers: asBearerAuth(accessToken) },
);
},

View File

@@ -79,6 +79,7 @@
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
"cron_expression_presets": "Cron expression presets",
"disable_login": "Disable login",
"download_csv": "Download CSV",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
"export_config_as_json_description": "Download the current system config as a JSON file",
@@ -191,6 +192,17 @@
"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_checksum_mismatch": "Checksum Mismatch",
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
"maintenance_integrity_checksum_mismatch_refresh_job": "Refresh checksum mismatch reports",
"maintenance_integrity_missing_file": "Missing Files",
"maintenance_integrity_missing_file_job": "Check for missing files",
"maintenance_integrity_missing_file_refresh_job": "Refresh missing file reports",
"maintenance_integrity_report": "Integrity Report",
"maintenance_integrity_untracked_file": "Untracked Files",
"maintenance_integrity_untracked_file_job": "Check for untracked files",
"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!",
@@ -915,6 +927,8 @@
"deduplicate_all": "Deduplicate All",
"default_locale": "Default Locale",
"default_locale_description": "Format dates and numbers based on your browser locale",
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
"default_share_quality": "Default share quality",
"delete": "Delete",
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
"delete_action_prompt": "{count} deleted",
@@ -1224,6 +1238,7 @@
"failed": "Failed",
"failed_count": "Failed: {count}",
"failed_to_authenticate": "Failed to authenticate",
"failed_to_delete_file": "Failed to delete file",
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder",
"favorite": "Favorite",
@@ -1354,6 +1369,7 @@
"individual_share": "Individual share",
"individual_shares": "Individual shares",
"info": "Info",
"integrity_checks": "Integrity Checks",
"interval": {
"day_at_onepm": "Every day at 1pm",
"hours": "Every {hours, plural, one {hour} other {{hours, number} hours}}",
@@ -1426,6 +1442,7 @@
"linked_oauth_account": "Linked OAuth account",
"list": "List",
"live": "Live",
"load_more": "Load More",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
"local": "Local",
@@ -2084,6 +2101,7 @@
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos",
"select_quality": "Select quality",
"select_trash_all": "Select trash all",
"select_user_for_sharing_page_err_album": "Failed to create album",
"selected": "Selected",
@@ -2147,6 +2165,8 @@
"share_assets_selected": "{count} selected",
"share_dialog_preparing": "Preparing...",
"share_link": "Share Link",
"share_original": "Use original (large)",
"share_preview": "Use thumbnail (small)",
"shared": "Shared",
"shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activity_remove_content": "Do you want to delete this activity?",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,14 +34,14 @@ void main() {
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
await Store.delete(StoreKey.syncMigrationStatus);
await Store.delete(StoreKey.legacySyncMigrationStatus);
});
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.serverEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
await Store.delete(StoreKey.legacyServerEndpoint);
await Store.delete(StoreKey.legacySyncMigrationStatus);
});
void sendUser(SyncStream stream, String id, String name) {
@@ -119,7 +119,9 @@ void main() {
final releaseTxn = Completer<void>();
final txnHeld = Completer<void>();
final txn = drift.transaction(() async {
await drift.into(drift.userEntity).insert(
await drift
.into(drift.userEntity)
.insert(
UserEntityCompanion.insert(
id: 'holder',
name: 'holder',

View File

@@ -13,6 +13,8 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }
enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }

View File

@@ -0,0 +1,20 @@
import 'package:immich_mobile/domain/models/value_codec.dart';
const int kCurrentVersion = 29;
enum AppMetadataKey<T> {
version<int>(kCurrentVersion),
syncMigrationStatus<List<String>>([], codec: ListCodec(PrimitiveCodec.string)),
manageLocalMediaAndroid<bool>(false);
const AppMetadataKey(this.defaultValue, {ValueCodec<T>? codec}) : _codecOverride = codec;
final T defaultValue;
final ValueCodec<T>? _codecOverride;
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}

View File

@@ -0,0 +1,33 @@
class AdvancedConfig {
final bool troubleshooting;
final bool enableHapticFeedback;
final bool readonlyModeEnabled;
const AdvancedConfig({
this.troubleshooting = false,
this.enableHapticFeedback = true,
this.readonlyModeEnabled = false,
});
AdvancedConfig copyWith({bool? troubleshooting, bool? enableHapticFeedback, bool? readonlyModeEnabled}) =>
AdvancedConfig(
troubleshooting: troubleshooting ?? this.troubleshooting,
enableHapticFeedback: enableHapticFeedback ?? this.enableHapticFeedback,
readonlyModeEnabled: readonlyModeEnabled ?? this.readonlyModeEnabled,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AdvancedConfig &&
other.troubleshooting == troubleshooting &&
other.enableHapticFeedback == enableHapticFeedback &&
other.readonlyModeEnabled == readonlyModeEnabled);
@override
int get hashCode => Object.hash(troubleshooting, enableHapticFeedback, readonlyModeEnabled);
@override
String toString() =>
'AdvancedConfig(troubleshooting: $troubleshooting, enableHapticFeedback: $enableHapticFeedback, readonlyModeEnabled: $readonlyModeEnabled)';
}

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/advanced_config.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/config/share_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
@@ -30,6 +32,8 @@ class AppConfig {
final AlbumConfig album;
final BackupConfig backup;
final NetworkConfig network;
final ShareConfig share;
final AdvancedConfig advanced;
const AppConfig({
this.logLevel = .info,
@@ -43,6 +47,8 @@ class AppConfig {
this.album = const .new(),
this.backup = const .new(),
this.network = const .new(),
this.share = const .new(),
this.advanced = const .new(),
});
AppConfig copyWith({
@@ -57,6 +63,8 @@ class AppConfig {
AlbumConfig? album,
BackupConfig? backup,
NetworkConfig? network,
ShareConfig? share,
AdvancedConfig? advanced,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
@@ -69,6 +77,8 @@ class AppConfig {
album: album ?? this.album,
backup: backup ?? this.backup,
network: network ?? this.network,
share: share ?? this.share,
advanced: advanced ?? this.advanced,
);
@override
@@ -85,17 +95,32 @@ class AppConfig {
other.slideshow == slideshow &&
other.album == album &&
other.backup == backup &&
other.network == network);
other.network == network &&
other.share == share &&
other.advanced == advanced);
@override
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
int get hashCode => Object.hash(
logLevel,
theme,
cleanup,
map,
timeline,
image,
viewer,
slideshow,
album,
backup,
network,
share,
advanced,
);
@override
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, advanced: $advanced)';
T read<T extends Object>(SettingsKey<T> key) =>
T read<T>(SettingsKey<T> key) =>
(switch (key) {
.logLevel => logLevel,
.themePrimaryColor => theme.primaryColor,
@@ -135,18 +160,22 @@ class AppConfig {
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
.shareFileType => share.fileType,
.slideshowTransition => slideshow.transition,
.slideshowRepeat => slideshow.repeat,
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
.slideshowDirection => slideshow.direction,
.advancedTroubleshooting => advanced.troubleshooting,
.advancedEnableHapticFeedback => advanced.enableHapticFeedback,
.advancedReadonlyModeEnabled => advanced.readonlyModeEnabled,
})
as T;
factory AppConfig.fromEntries(Map<SettingsKey<Object>, Object> overrides) =>
factory AppConfig.fromEntries(Map<SettingsKey, Object?> overrides) =>
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
AppConfig write<T extends Object>(SettingsKey<T> key, T value) {
AppConfig write<T, U extends T>(SettingsKey<T> key, U value) {
return switch (key) {
.logLevel => copyWith(logLevel: value as LogLevel),
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
@@ -160,8 +189,10 @@ class AppConfig {
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
.networkPreferredWifiName => copyWith(
network: network.copyWith(preferredWifiName: .fromNullable((value as String?))),
),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: .fromNullable((value as String?)))),
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
@@ -186,11 +217,15 @@ class AppConfig {
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
.shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)),
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
.advancedTroubleshooting => copyWith(advanced: advanced.copyWith(troubleshooting: value as bool)),
.advancedEnableHapticFeedback => copyWith(advanced: advanced.copyWith(enableHapticFeedback: value as bool)),
.advancedReadonlyModeEnabled => copyWith(advanced: advanced.copyWith(readonlyModeEnabled: value as bool)),
};
}
}

View File

@@ -1,30 +1,31 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/utils/option.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String preferredWifiName;
final String localEndpoint;
final String? preferredWifiName;
final String? localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName = '',
this.localEndpoint = '',
this.preferredWifiName,
this.localEndpoint,
this.externalEndpointList = const [],
this.customHeaders = const {},
});
NetworkConfig copyWith({
bool? autoEndpointSwitching,
String? preferredWifiName,
String? localEndpoint,
Option<String>? preferredWifiName,
Option<String>? localEndpoint,
List<String>? externalEndpointList,
Map<String, String>? customHeaders,
}) => NetworkConfig(
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
localEndpoint: localEndpoint ?? this.localEndpoint,
preferredWifiName: preferredWifiName.patch(this.preferredWifiName),
localEndpoint: localEndpoint.patch(this.localEndpoint),
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
customHeaders: customHeaders ?? this.customHeaders,
);

View File

@@ -0,0 +1,18 @@
import 'package:immich_mobile/constants/enums.dart';
class ShareConfig {
final ShareAssetType fileType;
const ShareConfig({this.fileType = ShareAssetType.original});
ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType);
@override
bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType);
@override
int get hashCode => fileType.hashCode;
@override
String toString() => 'ShareConfig(fileType: $fileType)';
}

View File

@@ -0,0 +1,63 @@
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/utils/option.dart';
enum SessionKey<T> {
serverUrl<String?>(),
accessToken<String?>(),
serverEndpoint<String?>();
ValueCodec<T> get _codec => ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
const defaultSession = Session();
class Session {
final String? serverUrl;
final String? accessToken;
final String? serverEndpoint;
const Session({this.serverUrl, this.accessToken, this.serverEndpoint});
Session copyWith({Option<String>? serverUrl, Option<String>? accessToken, Option<String>? serverEndpoint}) => .new(
serverUrl: serverUrl.patch(this.serverUrl),
accessToken: accessToken.patch(this.accessToken),
serverEndpoint: serverEndpoint.patch(this.serverEndpoint),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Session &&
other.serverUrl == serverUrl &&
other.accessToken == accessToken &&
other.serverEndpoint == serverEndpoint);
@override
int get hashCode => Object.hash(serverUrl, accessToken, serverEndpoint);
@override
String toString() => 'Session(serverUrl: $serverUrl, accessToken: $accessToken, serverEndpoint: $serverEndpoint)';
T read<T>(SessionKey<T> key) =>
(switch (key) {
.serverUrl => serverUrl,
.accessToken => accessToken,
.serverEndpoint => serverEndpoint,
})
as T;
factory Session.fromEntries(Map<SessionKey, Object?> overrides) =>
overrides.entries.fold(const Session(), (session, entry) => session.write(entry.key, entry.value));
Session write<T, U extends T>(SessionKey<T> key, U value) {
return switch (key) {
.serverUrl => copyWith(serverUrl: .fromNullable(value as String?)),
.accessToken => copyWith(accessToken: .fromNullable(value as String?)),
.serverEndpoint => copyWith(serverEndpoint: .fromNullable(value as String?)),
};
}
}

View File

@@ -1,10 +0,0 @@
import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false);
const Setting(this.storeKey, this.defaultValue);
final StoreKey<T> storeKey;
final T defaultValue;
}

View File

@@ -1,16 +1,15 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum SettingsKey<T extends Object> {
enum SettingsKey<T> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
@@ -26,13 +25,13 @@ enum SettingsKey<T extends Object> {
// Network
networkAutoEndpointSwitching<bool>(),
networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
networkPreferredWifiName<String?>(),
networkLocalEndpoint<String?>(),
// Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
@@ -46,172 +45,48 @@ enum SettingsKey<T extends Object> {
// Timeline
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
// Cleanup
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Share
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
final _SettingsCodec<T>? _codecOverride;
// Advanced
advancedTroubleshooting<bool>(),
advancedEnableHapticFeedback<bool>(),
advancedReadonlyModeEnabled<bool>();
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
final ValueCodec<T>? _codecOverride;
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
sealed class _SettingsCodec<T extends Object> {
const _SettingsCodec();
String encode(T value);
T decode(String raw);
static const Map<Type, _SettingsCodec<Object>> _primitives = {
int: _PrimitiveCodec.integer,
double: _PrimitiveCodec.real,
bool: _PrimitiveCodec.boolean,
String: _PrimitiveCodec.string,
DateTime: _DateTimeCodec(),
};
static _SettingsCodec<T> forType<T extends Object>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
}
return codec as _SettingsCodec<T>;
}
}
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
final List<T> values;
const _EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
const _DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return {};
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
final _SettingsCodec<T> _elementCodec;
const _ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
final T Function(String) _parse;
const _PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}

View File

@@ -1,24 +1,18 @@
import 'package:immich_mobile/domain/models/user.model.dart';
/// Key for each possible value in the `Store`.
/// Defines the data type for each value
enum StoreKey<T> {
version<int>._(0),
currentUser<UserDto>._(2),
deviceId<String>._(4),
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
advancedTroubleshooting<bool>._(114),
enableHapticFeedback<bool>._(126),
manageLocalMediaAndroid<bool>._(137),
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacyVersion<int>._(0),
legacyManageLocalMediaAndroid<bool>._(137),
legacySyncMigrationStatus<String>._(1013),
legacyAdvancedTroubleshooting<bool>._(114),
legacyEnableHapticFeedback<bool>._(126),
legacyReadonlyModeEnabled<bool>._(138),
legacyServerUrl<String>._(10),
legacyAccessToken<String>._(11),
legacyServerEndpoint<String>._(12),
legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8),
legacySyncAlbums<bool>._(131),

View File

@@ -0,0 +1,141 @@
import 'dart:convert';
sealed class ValueCodec<T> {
const ValueCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, ValueCodec<Object>> _primitives = {
..._register<int>(PrimitiveCodec.integer),
..._register<double>(PrimitiveCodec.real),
..._register<bool>(PrimitiveCodec.boolean),
..._register<String>(PrimitiveCodec.string),
..._register<DateTime>(const DateTimeCodec()),
};
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static ValueCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
}
return codec as ValueCodec<T>;
}
}
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
final List<T> values;
const EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class DateTimeCodec extends ValueCodec<DateTime> {
const DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
final ValueCodec<K> _keyCodec;
final ValueCodec<V> _valueCodec;
const MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
continue;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
final ValueCodec<T> _elementCodec;
const ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return const [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return const [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return const [];
}
}
}
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
final T Function(String) _parse;
const PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = PrimitiveCodec<int>._(int.parse);
static const real = PrimitiveCodec<double>._(double.parse);
static const boolean = PrimitiveCodec<bool>._(bool.parse);
static const string = PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}

View File

@@ -5,9 +5,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
@@ -28,6 +27,7 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final AppMetadataRepository _appMetadataRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService");
@@ -38,6 +38,7 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._appMetadataRepository,
this._cancellation,
}) {
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
@@ -48,7 +49,7 @@ class LocalSyncService {
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) {
final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();

View File

@@ -1,19 +0,0 @@
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
// Singleton instance of SettingsService, to use in places
// where reactivity is not required
// ignore: non_constant_identifier_names
final AppSetting = SettingsService(storeService: StoreService.I);
class SettingsService {
final StoreService _storeService;
const SettingsService({required this._storeService});
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
Future<void> set<T>(Setting<T> setting, T value) => _storeService.put(setting.storeKey, value);
Stream<T> watch<T>(Setting<T> setting) => _storeService.watch(setting.storeKey).map((v) => v ?? setting.defaultValue);
}

View File

@@ -2,13 +2,12 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
@@ -18,7 +17,7 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
ref.watch(authUserRepositoryProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -27,14 +26,14 @@ class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
final DriftAuthUserRepository _authUserRepository;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService, {
this._authUserRepository, {
this._cancellation,
});
@@ -123,11 +122,12 @@ class SyncLinkedAlbumService {
/// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
dPrint(() => "Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(
localAlbum.name,
_storeService.get(StoreKey.currentUser),
assetIds: [],
);
final currentUser = await _authUserRepository.get();
if (currentUser == null) {
_log.warning("No user logged in, skipping remote album creation for local album: ${localAlbum.name}");
return;
}
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, currentUser, assetIds: []);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}

View File

@@ -1,13 +1,11 @@
// ignore_for_file: constant_identifier_names
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
@@ -38,6 +36,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final AppMetadataRepository _appMetadataRepository;
final Completer<void>? _cancellation;
SyncStreamService({
@@ -49,10 +48,12 @@ class SyncStreamService {
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
required this._appMetadataRepository,
this._cancellation,
});
bool get isCancelled => _cancellation?.isCompleted ?? false;
bool _manageLocalMediaAndroid = false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
@@ -64,16 +65,17 @@ class SyncStreamService {
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>();
final migrations = (await _appMetadataRepository.get(.syncMigrationStatus)).toList();
int previousLength = migrations.length;
await _runPreSyncTasks(migrations, serverSemVer);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
await _appMetadataRepository.set(.syncMigrationStatus, migrations);
}
_manageLocalMediaAndroid = CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid);
// Start the sync stream and handle events
bool shouldReset = false;
await _syncApiRepository.streamChanges(
@@ -96,7 +98,7 @@ class SyncStreamService {
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
await _appMetadataRepository.set(.syncMigrationStatus, migrations);
}
return true;
@@ -106,10 +108,10 @@ class SyncStreamService {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
.assetExifV1,
.partnerAssetExifV1,
.albumAssetExifCreateV1,
.albumAssetExifUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name);
}
@@ -117,12 +119,7 @@ class SyncStreamService {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]);
await _syncApiRepository.deleteSyncAck([.assetV1, .partnerAssetV1, .albumAssetCreateV1, .albumAssetUpdateV1]);
migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name);
@@ -134,7 +131,7 @@ class SyncStreamService {
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
await _syncApiRepository.deleteSyncAck([.assetV1, .assetV2]);
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);
}
}
@@ -197,20 +194,20 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (_manageLocalMediaAndroid) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (_manageLocalMediaAndroid) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (_manageLocalMediaAndroid) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);

View File

@@ -1,29 +1,24 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:logging/logging.dart';
class UserService {
final Logger _log = Logger("UserService");
final UserApiRepository _userApiRepository;
final StoreService _storeService;
final DriftAuthUserRepository _authUserRepository;
UserService({required this._userApiRepository, required this._storeService});
UserService({required this._userApiRepository, required this._authUserRepository});
UserDto getMyUser() {
return _storeService.get(StoreKey.currentUser);
}
UserDto? tryGetMyUser() {
return _storeService.tryGet(StoreKey.currentUser);
Future<UserDto?> tryGetMyUser() {
return _authUserRepository.get();
}
Stream<UserDto?> watchMyUser() {
return _storeService.watch(StoreKey.currentUser);
return _authUserRepository.watch();
}
Future<UserDto?> refreshMyUser() async {
@@ -31,15 +26,17 @@ class UserService {
if (user == null) {
return null;
}
await _storeService.put(StoreKey.currentUser, user);
await _authUserRepository.upsert(user);
return user;
}
Future<String?> createProfileImage(String name, Uint8List image) async {
try {
final path = await _userApiRepository.createProfileImage(name: name, data: image);
final updatedUser = getMyUser();
await _storeService.put(StoreKey.currentUser, updatedUser);
final updatedUser = await tryGetMyUser();
if (updatedUser != null) {
await _authUserRepository.upsert(updatedUser);
}
return path;
} catch (e) {
_log.warning("Failed to upload profile image", e);

View File

@@ -1,14 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:logging/logging.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
final user = Store.tryGet(StoreKey.currentUser);
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) async {
final user = await ref.read(authUserRepositoryProvider).get();
if (user == null) {
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
return Future.value();
return;
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
}

View File

@@ -0,0 +1,18 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class AppMetadataEntity extends Table with DriftDefaultsMixin {
const AppMetadataEntity();
TextColumn get key => text()();
TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};
@override
String get tableName => "app_metadata";
}

View File

@@ -0,0 +1,434 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$AppMetadataEntityTableCreateCompanionBuilder =
i1.AppMetadataEntityCompanion Function({
required String key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
typedef $$AppMetadataEntityTableUpdateCompanionBuilder =
i1.AppMetadataEntityCompanion Function({
i0.Value<String> key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
class $$AppMetadataEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
$$AppMetadataEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$AppMetadataEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
$$AppMetadataEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$AppMetadataEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
$$AppMetadataEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get key =>
$composableBuilder(column: $table.key, builder: (column) => column);
i0.GeneratedColumn<String> get value =>
$composableBuilder(column: $table.value, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$AppMetadataEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData,
i1.$$AppMetadataEntityTableFilterComposer,
i1.$$AppMetadataEntityTableOrderingComposer,
i1.$$AppMetadataEntityTableAnnotationComposer,
$$AppMetadataEntityTableCreateCompanionBuilder,
$$AppMetadataEntityTableUpdateCompanionBuilder,
(
i1.AppMetadataEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData
>,
),
i1.AppMetadataEntityData,
i0.PrefetchHooks Function()
> {
$$AppMetadataEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$AppMetadataEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$AppMetadataEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () => i1
.$$AppMetadataEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$AppMetadataEntityTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.AppMetadataEntityCompanion(
key: key,
value: value,
updatedAt: updatedAt,
),
createCompanionCallback:
({
required String key,
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.AppMetadataEntityCompanion.insert(
key: key,
value: value,
updatedAt: updatedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$AppMetadataEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData,
i1.$$AppMetadataEntityTableFilterComposer,
i1.$$AppMetadataEntityTableOrderingComposer,
i1.$$AppMetadataEntityTableAnnotationComposer,
$$AppMetadataEntityTableCreateCompanionBuilder,
$$AppMetadataEntityTableUpdateCompanionBuilder,
(
i1.AppMetadataEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData
>,
),
i1.AppMetadataEntityData,
i0.PrefetchHooks Function()
>;
class $AppMetadataEntityTable extends i2.AppMetadataEntity
with i0.TableInfo<$AppMetadataEntityTable, i1.AppMetadataEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$AppMetadataEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
@override
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
'key',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
'value',
);
@override
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
);
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime,
);
@override
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'app_metadata';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.AppMetadataEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('key')) {
context.handle(
_keyMeta,
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
);
} else if (isInserting) {
context.missing(_keyMeta);
}
if (data.containsKey('value')) {
context.handle(
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {key};
@override
i1.AppMetadataEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.AppMetadataEntityData(
key: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}key'],
)!,
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
),
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
);
}
@override
$AppMetadataEntityTable createAlias(String alias) {
return $AppMetadataEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class AppMetadataEntityData extends i0.DataClass
implements i0.Insertable<i1.AppMetadataEntityData> {
final String key;
final String? value;
final DateTime updatedAt;
const AppMetadataEntityData({
required this.key,
this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
factory AppMetadataEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return AppMetadataEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String?>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String?>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.AppMetadataEntityData copyWith({
String? key,
i0.Value<String?> value = const i0.Value.absent(),
DateTime? updatedAt,
}) => i1.AppMetadataEntityData(
key: key ?? this.key,
value: value.present ? value.value : this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
AppMetadataEntityData copyWithCompanion(i1.AppMetadataEntityCompanion data) {
return AppMetadataEntityData(
key: data.key.present ? data.key.value : this.key,
value: data.value.present ? data.value.value : this.value,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('AppMetadataEntityData(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(key, value, updatedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.AppMetadataEntityData &&
other.key == this.key &&
other.value == this.value &&
other.updatedAt == this.updatedAt);
}
class AppMetadataEntityCompanion
extends i0.UpdateCompanion<i1.AppMetadataEntityData> {
final i0.Value<String> key;
final i0.Value<String?> value;
final i0.Value<DateTime> updatedAt;
const AppMetadataEntityCompanion({
this.key = const i0.Value.absent(),
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
});
AppMetadataEntityCompanion.insert({
required String key,
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key);
static i0.Insertable<i1.AppMetadataEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
i0.Expression<DateTime>? updatedAt,
}) {
return i0.RawValuesInsertable({
if (key != null) 'key': key,
if (value != null) 'value': value,
if (updatedAt != null) 'updated_at': updatedAt,
});
}
i1.AppMetadataEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String?>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.AppMetadataEntityCompanion(
key: key ?? this.key,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (key.present) {
map['key'] = i0.Variable<String>(key.value);
}
if (value.present) {
map['value'] = i0.Variable<String>(value.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('AppMetadataEntityCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
}

View File

@@ -0,0 +1,18 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class SessionEntity extends Table with DriftDefaultsMixin {
const SessionEntity();
TextColumn get key => text()();
TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};
@override
String get tableName => "session";
}

View File

@@ -0,0 +1,427 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/session.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$SessionEntityTableCreateCompanionBuilder =
i1.SessionEntityCompanion Function({
required String key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
typedef $$SessionEntityTableUpdateCompanionBuilder =
i1.SessionEntityCompanion Function({
i0.Value<String> key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
class $$SessionEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
$$SessionEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$SessionEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
$$SessionEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$SessionEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
$$SessionEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get key =>
$composableBuilder(column: $table.key, builder: (column) => column);
i0.GeneratedColumn<String> get value =>
$composableBuilder(column: $table.value, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$SessionEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData,
i1.$$SessionEntityTableFilterComposer,
i1.$$SessionEntityTableOrderingComposer,
i1.$$SessionEntityTableAnnotationComposer,
$$SessionEntityTableCreateCompanionBuilder,
$$SessionEntityTableUpdateCompanionBuilder,
(
i1.SessionEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData
>,
),
i1.SessionEntityData,
i0.PrefetchHooks Function()
> {
$$SessionEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$SessionEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$SessionEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$SessionEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$SessionEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SessionEntityCompanion(
key: key,
value: value,
updatedAt: updatedAt,
),
createCompanionCallback:
({
required String key,
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SessionEntityCompanion.insert(
key: key,
value: value,
updatedAt: updatedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$SessionEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData,
i1.$$SessionEntityTableFilterComposer,
i1.$$SessionEntityTableOrderingComposer,
i1.$$SessionEntityTableAnnotationComposer,
$$SessionEntityTableCreateCompanionBuilder,
$$SessionEntityTableUpdateCompanionBuilder,
(
i1.SessionEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData
>,
),
i1.SessionEntityData,
i0.PrefetchHooks Function()
>;
class $SessionEntityTable extends i2.SessionEntity
with i0.TableInfo<$SessionEntityTable, i1.SessionEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$SessionEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
@override
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
'key',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
'value',
);
@override
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
);
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime,
);
@override
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'session';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.SessionEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('key')) {
context.handle(
_keyMeta,
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
);
} else if (isInserting) {
context.missing(_keyMeta);
}
if (data.containsKey('value')) {
context.handle(
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {key};
@override
i1.SessionEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.SessionEntityData(
key: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}key'],
)!,
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
),
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
);
}
@override
$SessionEntityTable createAlias(String alias) {
return $SessionEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class SessionEntityData extends i0.DataClass
implements i0.Insertable<i1.SessionEntityData> {
final String key;
final String? value;
final DateTime updatedAt;
const SessionEntityData({
required this.key,
this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
factory SessionEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return SessionEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String?>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String?>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.SessionEntityData copyWith({
String? key,
i0.Value<String?> value = const i0.Value.absent(),
DateTime? updatedAt,
}) => i1.SessionEntityData(
key: key ?? this.key,
value: value.present ? value.value : this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
SessionEntityData copyWithCompanion(i1.SessionEntityCompanion data) {
return SessionEntityData(
key: data.key.present ? data.key.value : this.key,
value: data.value.present ? data.value.value : this.value,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('SessionEntityData(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(key, value, updatedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.SessionEntityData &&
other.key == this.key &&
other.value == this.value &&
other.updatedAt == this.updatedAt);
}
class SessionEntityCompanion extends i0.UpdateCompanion<i1.SessionEntityData> {
final i0.Value<String> key;
final i0.Value<String?> value;
final i0.Value<DateTime> updatedAt;
const SessionEntityCompanion({
this.key = const i0.Value.absent(),
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
});
SessionEntityCompanion.insert({
required String key,
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key);
static i0.Insertable<i1.SessionEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
i0.Expression<DateTime>? updatedAt,
}) {
return i0.RawValuesInsertable({
if (key != null) 'key': key,
if (value != null) 'value': value,
if (updatedAt != null) 'updated_at': updatedAt,
});
}
i1.SessionEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String?>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.SessionEntityCompanion(
key: key ?? this.key,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (key.present) {
map['key'] = i0.Variable<String>(key.value);
}
if (value.present) {
map['value'] = i0.Variable<String>(value.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SessionEntityCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
}

View File

@@ -6,7 +6,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
TextColumn get key => text()();
TextColumn get value => text()();
TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();

View File

@@ -10,13 +10,13 @@ import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$SettingsEntityTableCreateCompanionBuilder =
i1.SettingsEntityCompanion Function({
required String key,
required String value,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
typedef $$SettingsEntityTableUpdateCompanionBuilder =
i1.SettingsEntityCompanion Function({
i0.Value<String> key,
i0.Value<String> value,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
@@ -127,7 +127,7 @@ class $$SettingsEntityTableTableManager
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String> value = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SettingsEntityCompanion(
key: key,
@@ -137,7 +137,7 @@ class $$SettingsEntityTableTableManager
createCompanionCallback:
({
required String key,
required String value,
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SettingsEntityCompanion.insert(
key: key,
@@ -196,9 +196,9 @@ class $SettingsEntityTable extends i2.SettingsEntity
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
false,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
@@ -240,8 +240,6 @@ class $SettingsEntityTable extends i2.SettingsEntity
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
} else if (isInserting) {
context.missing(_valueMeta);
}
if (data.containsKey('updated_at')) {
context.handle(
@@ -265,7 +263,7 @@ class $SettingsEntityTable extends i2.SettingsEntity
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
)!,
),
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
@@ -287,18 +285,20 @@ class $SettingsEntityTable extends i2.SettingsEntity
class SettingsEntityData extends i0.DataClass
implements i0.Insertable<i1.SettingsEntityData> {
final String key;
final String value;
final String? value;
final DateTime updatedAt;
const SettingsEntityData({
required this.key,
required this.value,
this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
map['value'] = i0.Variable<String>(value);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
@@ -310,7 +310,7 @@ class SettingsEntityData extends i0.DataClass
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return SettingsEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String>(json['value']),
value: serializer.fromJson<String?>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@@ -319,18 +319,18 @@ class SettingsEntityData extends i0.DataClass
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String>(value),
'value': serializer.toJson<String?>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.SettingsEntityData copyWith({
String? key,
String? value,
i0.Value<String?> value = const i0.Value.absent(),
DateTime? updatedAt,
}) => i1.SettingsEntityData(
key: key ?? this.key,
value: value ?? this.value,
value: value.present ? value.value : this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
SettingsEntityData copyWithCompanion(i1.SettingsEntityCompanion data) {
@@ -365,7 +365,7 @@ class SettingsEntityData extends i0.DataClass
class SettingsEntityCompanion
extends i0.UpdateCompanion<i1.SettingsEntityData> {
final i0.Value<String> key;
final i0.Value<String> value;
final i0.Value<String?> value;
final i0.Value<DateTime> updatedAt;
const SettingsEntityCompanion({
this.key = const i0.Value.absent(),
@@ -374,10 +374,9 @@ class SettingsEntityCompanion
});
SettingsEntityCompanion.insert({
required String key,
required String value,
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key),
value = i0.Value(value);
}) : key = i0.Value(key);
static i0.Insertable<i1.SettingsEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
@@ -392,7 +391,7 @@ class SettingsEntityCompanion
i1.SettingsEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String>? value,
i0.Value<String?>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.SettingsEntityCompanion(

View File

@@ -0,0 +1,28 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class AppMetadataRepository {
final Drift _db;
const AppMetadataRepository(this._db);
Future<T> get<T>(AppMetadataKey<T> key) async {
final row = await (_db.select(_db.appMetadataEntity)..where((row) => row.key.equals(key.name))).getSingleOrNull();
final value = row?.value;
return value == null ? key.defaultValue : key.decode(value);
}
Future<void> set<T, U extends T>(AppMetadataKey<T> key, U value) async {
await _db
.into(_db.appMetadataEntity)
.insertOnConflictUpdate(
AppMetadataEntityCompanion.insert(
key: key.name,
value: Value(key.encode(value)),
updatedAt: Value(DateTime.now()),
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
abstract class CachedKeyValueRepository<K extends Enum, S> {
CachedKeyValueRepository(this._snapshot);
S _snapshot;
S get snapshot => _snapshot;
@protected
set snapshot(S value) => _snapshot = value;
List<K> get keys;
Object decodeValue(K key, String raw);
S buildSnapshot(Map<K, Object?> overrides);
Selectable<({String key, String? value})> selectable();
Future<void> refresh() async => _snapshot = _build(await selectable().get());
@protected
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
S _build(List<({String key, String? value})> rows) => buildSnapshot(
rows.fold({}, (overrides, row) {
final key = keys.firstWhereOrNull((key) => key.name == row.key);
if (key == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = decodeValue(key, row.value!);
}
return {...overrides, key: decodedValue};
}),
);
}

View File

@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
import 'package:drift/src/runtime/executor/stream_queries.dart' show StreamQueryStore;
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
@@ -25,6 +26,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
@@ -67,6 +69,8 @@ import 'package:sqlite_async/sqlite_async.dart';
AssetEditEntity,
SettingsEntity,
AssetOcrEntity,
SessionEntity,
AppMetadataEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -120,7 +124,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 29;
int get schemaVersion => 32;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -308,6 +312,20 @@ class Drift extends $Drift {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
from30To31: (m, v31) async {
await m.createTable(v31.session);
},
from31To32: (m, v32) async {
await m.createTable(v32.appMetadata);
await customStatement(
"INSERT INTO app_metadata (key, value) "
"SELECT 'version', CAST(int_value AS TEXT) FROM store_entity "
"WHERE id = 0 AND int_value IS NOT NULL",
);
},
),
);

View File

@@ -47,9 +47,13 @@ import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart
as i22;
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'
as i25;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i26;
import 'package:drift/internal/modular.dart' as i27;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -99,9 +103,14 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
this,
);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
late final i24.$SessionEntityTable sessionEntity = i24.$SessionEntityTable(
this,
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
);
late final i25.$AppMetadataEntityTable appMetadataEntity = i25
.$AppMetadataEntityTable(this);
i26.MergedAssetDrift get mergedAssetDrift => i27.ReadDatabaseContainer(
this,
).accessor<i26.MergedAssetDrift>(i26.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -140,6 +149,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetEditEntity,
settingsEntity,
assetOcrEntity,
sessionEntity,
appMetadataEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -414,4 +425,8 @@ class $DriftManager {
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
i24.$$SessionEntityTableTableManager get sessionEntity =>
i24.$$SessionEntityTableTableManager(_db, _db.sessionEntity);
i25.$$AppMetadataEntityTableTableManager get appMetadataEntity =>
i25.$$AppMetadataEntityTableTableManager(_db, _db.appMetadataEntity);
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,20 +16,21 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
}
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
final query =
_db.select(_db.assetFaceEntity).join([
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
])..where(
_db.assetFaceEntity.assetId.equals(assetId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull() &
_db.personEntity.isHidden.equals(false),
);
// An asset can have multiple face records for the same person (e.g., metadata
// imports alongside ML detections). Use a subquery instead of a join so each
// person is returned once, regardless of how many of their faces are on the asset
final faceQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.personId])
..where(
_db.assetFaceEntity.assetId.equals(assetId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
return query.map((row) {
final person = row.readTable(_db.personEntity);
return person.toDto();
}).get();
final query = _db.select(_db.personEntity)
..where((row) => row.id.isInQuery(faceQuery) & row.isHidden.equals(false));
return query.map((row) => row.toDto()).get();
}
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) async {

View File

@@ -194,12 +194,15 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) {
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)),
RemoteExifEntityCompanion(
dateTimeOriginal: Value(dateTime),
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
),
where: (e) => e.assetId.equals(id),
);
batch.update(

View File

@@ -0,0 +1,82 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SessionRepository extends CachedKeyValueRepository<SessionKey, Session> {
final Drift _db;
SessionRepository._(this._db) : super(const .new());
static SessionRepository? _instance;
static SessionRepository get instance {
final instance = _instance;
if (instance == null) {
throw StateError('SessionRepository not initialized. Call ensureInitialized() first');
}
return instance;
}
static Future<SessionRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SessionRepository._(db);
await instance.refresh();
_instance = instance;
}
return _instance!;
}
@override
List<SessionKey> get keys => SessionKey.values;
@override
Object decodeValue(SessionKey key, String raw) => key.decode(raw);
@override
Session buildSnapshot(Map<SessionKey, Object?> overrides) => Session.fromEntries(overrides);
@override
@protected
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.sessionEntity).map((row) => (key: row.key, value: row.value));
Session get session => snapshot;
Future<void> clear(Iterable<SessionKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.sessionEntity)..where((row) => row.key.isIn(names))).go();
var session = snapshot;
for (final key in keys) {
session = session.write(key, defaultSession.read(key));
}
snapshot = session;
}
Future<void> write<T, U extends T>(SessionKey<T> key, U value) async {
if (value == snapshot.read(key)) {
return;
}
String? resolvedValue;
if (value != null) {
resolvedValue = key.encode(value);
}
await _db
.into(_db.sessionEntity)
.insertOnConflictUpdate(
SessionEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
);
snapshot = snapshot.write(key, value);
}
Stream<Session> watch() => watchSnapshot();
}

View File

@@ -1,14 +1,15 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SettingsRepository extends DriftDatabaseRepository {
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
final Drift _db;
SettingsRepository._(this._db) : super(_db);
SettingsRepository._(this._db) : super(const .new());
static SettingsRepository? _instance;
@@ -20,9 +21,6 @@ class SettingsRepository extends DriftDatabaseRepository {
return instance;
}
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
static Future<SettingsRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SettingsRepository._(db);
@@ -32,7 +30,21 @@ class SettingsRepository extends DriftDatabaseRepository {
return _instance!;
}
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
@override
List<SettingsKey> get keys => SettingsKey.values;
@override
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
@override
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
@override
@protected
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
AppConfig get appConfig => snapshot;
Future<void> clear(Iterable<SettingsKey> keys) async {
if (keys.isEmpty) {
@@ -42,13 +54,15 @@ class SettingsRepository extends DriftDatabaseRepository {
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
var config = snapshot;
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
config = config.write(key, defaultConfig.read(key));
}
snapshot = config;
}
Future<void> write<T extends Object, U extends T>(SettingsKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
if (value == snapshot.read(key)) {
return;
}
@@ -56,29 +70,18 @@ class SettingsRepository extends DriftDatabaseRepository {
return clear([key]);
}
String? resolvedValue;
if (value != null) {
resolvedValue = key.encode(value);
}
await _db
.into(_db.settingsEntity)
.insertOnConflictUpdate(
SettingsEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
);
_appConfig = _appConfig.write(key, value);
snapshot = snapshot.write(key, value);
}
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
void _applyOverrides(List<SettingsEntityData> rows) {
_appConfig = AppConfig.fromEntries(
rows.fold({}, (overrides, row) {
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
if (metadataKey == null) {
return overrides;
}
return {...overrides, metadataKey: metadataKey.decode(row.value)};
}),
);
}
Stream<AppConfig> watch() => watchSnapshot();
}

View File

@@ -1,9 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
class DriftStoreRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -63,8 +61,6 @@ class DriftStoreRepository extends DriftDatabaseRepository {
const (String) => entity.stringValue,
const (bool) => entity.intValue == 1,
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
const (UserDto) =>
entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!),
_ => null,
}
as T?;
@@ -75,7 +71,6 @@ class DriftStoreRepository extends DriftDatabaseRepository {
const (String) => (null, value as String),
const (bool) => ((value as bool) ? 1 : 0, null),
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id),
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
};
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));

View File

@@ -17,16 +17,15 @@ class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
Future<UserDto?> get(String id) async {
final user = await _db.managers.authUserEntity.filter((user) => user.id.equals(id)).getSingleOrNull();
Selectable<UserDto?> get _authUserQuery => (_db.authUserEntity.select()..limit(1)).asyncMap(_toDto);
if (user == null) {
return null;
}
Future<UserDto?> get() => _authUserQuery.getSingleOrNull();
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id));
Stream<UserDto?> watch() => _authUserQuery.watchSingleOrNull();
Future<UserDto> _toDto(AuthUserEntityData user) async {
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id));
final metadata = await query.map((row) => row.toDto()).get();
return user.toDto(metadata);
}

View File

@@ -5,8 +5,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
@@ -85,7 +83,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
final backupSyncManager = ref.read(backgroundSyncProvider);
Future<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}

View File

@@ -8,14 +8,15 @@ import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -306,9 +307,10 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
void resumeSession() async {
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
final accessToken = Store.tryGet(StoreKey.accessToken);
final session = ref.read(sessionProvider);
final serverUrl = session.serverUrl;
final endpoint = session.serverEndpoint;
final accessToken = session.accessToken;
if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
@@ -316,6 +318,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
final authUserRepository = ref.read(authUserRepositoryProvider);
unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -335,9 +338,9 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider);
_resumeBackup(backupProvider, authUserRepository);
}),
_resumeBackup(backupProvider),
_resumeBackup(backupProvider, authUserRepository),
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
@@ -373,11 +376,11 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
}
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
Future<void> _resumeBackup(DriftBackupNotifier notifier, DriftAuthUserRepository authUserRepository) async {
final isEnableBackup = SettingsRepository.instance.appConfig.backup.enabled;
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
final currentUser = await authUserRepository.get();
if (currentUser != null) {
unawaited(notifier.startForegroundBackup(currentUser.id));
}

View File

@@ -42,6 +42,7 @@ class BaseActionButton extends ConsumerWidget {
return IconButton(
onPressed: onPressed,
onLongPress: onLongPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
);
}

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -22,7 +21,7 @@ class OpenInBrowserActionButton extends ConsumerWidget {
});
void _onTap() async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
final serverEndpoint = SessionRepository.instance.session.serverEndpoint!.replaceFirst('/api', '');
String originPath = '';
switch (origin) {

View File

@@ -6,10 +6,12 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -48,6 +50,34 @@ class _SharePreparingDialog extends StatelessWidget {
}
}
class _ShareFileTypeDialog extends StatelessWidget {
const _ShareFileTypeDialog();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(context.t.select_quality),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.high_quality_rounded),
title: Text(context.t.share_original),
onTap: () => context.pop(ShareAssetType.original),
),
ListTile(
leading: const Icon(Icons.photo_size_select_large_rounded),
title: Text(context.t.share_preview),
onTap: () => context.pop(ShareAssetType.preview),
),
],
),
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
);
}
}
class ShareActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
@@ -60,6 +90,35 @@ class ShareActionButton extends ConsumerWidget {
return;
}
final fileType = ref.read(appConfigProvider).share.fileType;
await _share(context, ref, fileType);
}
void _onLongPress(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final fileType = await showDialog<ShareAssetType>(
context: context,
builder: (_) => const _ShareFileTypeDialog(),
useRootNavigator: false,
);
if (fileType == null || !context.mounted) {
return;
}
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
if (!context.mounted) {
return;
}
await _share(context, ref, fileType);
}
Future<void> _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async {
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
@@ -71,6 +130,7 @@ class ShareActionButton extends ConsumerWidget {
.shareAssets(
source,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
@@ -84,7 +144,7 @@ class ShareActionButton extends ConsumerWidget {
if (!result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
msg: context.t.scaffold_body_error_occurred,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
@@ -110,10 +170,11 @@ class ShareActionButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: 'share'.t(context: context),
label: context.t.share,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
onLongPressed: () => _onLongPress(context, ref),
);
}
}

View File

@@ -96,6 +96,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
switch (event) {
case ViewerShowDetailsEvent():
_showDetails();
case TimelineReloadEvent():
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset != _asset) {
setState(() => _asset = asset);
}
default:
}
}

View File

@@ -4,8 +4,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -13,6 +11,7 @@ import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.pro
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
@@ -143,7 +142,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final serverEndpoint = ref.read(sessionProvider).serverEndpoint!;
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null

View File

@@ -6,7 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -33,7 +33,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
final advancedTroubleshooting = ref.watch(appConfigProvider.select((c) => c.advanced.troubleshooting));
final actionContext = ActionButtonContext(
asset: asset,

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
@@ -24,7 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -56,7 +55,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final advancedTroubleshooting = ref.watch(appConfigProvider.select((c) => c.advanced.troubleshooting));
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);

View File

@@ -72,9 +72,6 @@ class MapStateNotifier extends Notifier<MapState> {
}
void switchTheme(ThemeMode mode) {
// TODO: Remove this line when map theme provider is removed
// Until then, keep both in sync as MapThemeOverride uses map state provider
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
state = state.copyWith(themeMode: mode);
}

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
class PartnerUserAvatar extends StatelessWidget {
class PartnerUserAvatar extends ConsumerWidget {
const PartnerUserAvatar({super.key, required this.userId, required this.name});
final String userId;
final String name;
@override
Widget build(BuildContext context) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image";
Widget build(BuildContext context, WidgetRef ref) {
final url = "${ref.read(sessionProvider).serverEndpoint}/users/$userId/profile-image";
final nameFirstLetter = name.isNotEmpty ? name[0] : "";
return CircleAvatar(
radius: 16,

View File

@@ -1,9 +1,7 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
@@ -13,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:logging/logging.dart';
@@ -140,7 +139,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final isEnableBackup = _ref.read(appConfigProvider).backup.enabled;
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
final currentUser = _ref.read(currentUserProvider);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),

View File

@@ -1,4 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
@@ -10,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -125,10 +127,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<bool> saveAuthInfo({required String accessToken}) async {
await Store.put(StoreKey.accessToken, accessToken);
await _ref.read(sessionRepository).write(SessionKey.accessToken, accessToken);
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final serverEndpoint = _ref.read(sessionProvider).serverEndpoint!;
final headerMap = _ref.read(appConfigProvider).network.customHeaders;
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
@@ -136,7 +138,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
UserDto? user = _userService.tryGetMyUser();
UserDto? user = await _userService.tryGetMyUser();
try {
final serverUser = await _userService.refreshMyUser().timeout(_timeoutDuration);
@@ -193,9 +195,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
return _ref.read(appConfigProvider).network.localEndpoint;
}
/// Returns the current server endpoint (with /api) URL from the store
/// Returns the current server endpoint (with /api) URL from the session
String? getServerEndpoint() {
return Store.tryGet(StoreKey.serverEndpoint);
return _ref.read(sessionProvider).serverEndpoint;
}
Future<String?> setOpenApiServiceEndpoint() {

View File

@@ -1,7 +1,6 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
final hapticFeedbackProvider = StateNotifierProvider<HapticNotifier, void>((ref) {
return HapticNotifier(ref);
@@ -14,31 +13,31 @@ class HapticNotifier extends StateNotifier<void> {
HapticNotifier(this._ref) : super(null);
selectionClick() {
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
HapticFeedback.selectionClick();
}
}
lightImpact() {
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
HapticFeedback.lightImpact();
}
}
mediumImpact() {
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
HapticFeedback.mediumImpact();
}
}
heavyImpact() {
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
HapticFeedback.heavyImpact();
}
}
vibrate() {
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
HapticFeedback.vibrate();
}
}

View File

@@ -353,6 +353,10 @@ class ActionNotifier extends Notifier<void> {
return null;
}
if (source == ActionSource.viewer) {
ref.invalidate(assetExifProvider);
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit date and time for assets', error, stack);
@@ -514,19 +518,21 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final ids = _getAssets(source).toList(growable: false);
try {
await _service.shareAssets(
final count = await _service.shareAssets(
ids,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: ids.length, success: true);
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());

View File

@@ -0,0 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final appMetadataRepositoryProvider = Provider<AppMetadataRepository>(
(ref) => AppMetadataRepository(ref.watch(driftProvider)),
);

View File

@@ -1,22 +1,19 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class ReadOnlyModeNotifier extends Notifier<bool> {
late AppSettingsService _appSettingService;
@override
bool build() {
_appSettingService = ref.read(appSettingsServiceProvider);
final readonlyMode = _appSettingService.getSetting(AppSettingsEnum.readonlyModeEnabled);
return readonlyMode;
return ref.read(appConfigProvider).advanced.readonlyModeEnabled;
}
void setMode(bool value) {
final isLoggedIn = ref.read(authProvider).isAuthenticated;
_appSettingService.setSetting(AppSettingsEnum.readonlyModeEnabled, value);
unawaited(ref.read(settingsProvider).write(.advancedReadonlyModeEnabled, value));
state = value;
if (value && isLoggedIn) {

View File

@@ -0,0 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
final sessionRepository = Provider.autoDispose<SessionRepository>((_) => SessionRepository.instance);
final sessionProvider = Provider.autoDispose<Session>((ref) {
final repo = ref.watch(sessionRepository);
final subscription = repo.watch().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.session;
});

View File

@@ -1,20 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
class SettingsNotifier extends Notifier<SettingsService> {
@override
SettingsService build() => SettingsService(storeService: ref.read(storeServiceProvider));
T get<T>(Setting<T> setting) => state.get(setting);
Future<void> set<T>(Setting<T> setting, T value) async {
await state.set(setting, value);
ref.invalidateSelf();
}
Stream<T> watch<T>(Setting<T> setting) => state.watch(setting);
}
final settingsProvider = NotifierProvider<SettingsNotifier, SettingsService>(SettingsNotifier.new);

View File

@@ -6,7 +6,7 @@ final settingsProvider = Provider.autoDispose<SettingsRepository>((_) => Setting
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
final repo = ref.watch(settingsProvider);
final subscription = repo.watchConfig().listen((event) => ref.state = event);
final subscription = repo.watch().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.appConfig;
});

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_migration.reposit
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@@ -26,6 +27,7 @@ final syncStreamServiceProvider = Provider(
permissionRepository: ref.watch(permissionRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
appMetadataRepository: ref.watch(appMetadataRepositoryProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -42,6 +44,7 @@ final localSyncServiceProvider = Provider(
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
appMetadataRepository: ref.watch(appMetadataRepositoryProvider),
cancellation: ref.watch(cancellationProvider),
),
);

View File

@@ -6,17 +6,18 @@ import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider)));
final authUserRepositoryProvider = Provider((ref) => DriftAuthUserRepository(ref.watch(driftProvider)));
final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi));
final userServiceProvider = Provider(
(ref) => UserService(
userApiRepository: ref.watch(userApiRepositoryProvider),
storeService: ref.watch(storeServiceProvider),
authUserRepository: ref.watch(authUserRepositoryProvider),
),
);

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
class CurrentUserProvider extends StateNotifier<UserDto?> {
CurrentUserProvider(this._userService) : super(null) {
state = _userService.tryGetMyUser();
_userService.tryGetMyUser().then((user) => state = user ?? state);
streamSub = _userService.watchMyUser().listen((user) => state = user ?? state);
}

View File

@@ -1,12 +1,11 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
@@ -68,7 +67,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) {
try {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
final endpoint = Uri.parse(_ref.read(sessionProvider).serverEndpoint!);
dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(

View File

@@ -59,10 +59,8 @@ class AssetApiRepository extends ApiRepository {
);
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
return _api.updateAssets(
AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime.toIso8601String())),
);
Future<void> updateDateTime(List<String> ids, String dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime)));
}
Future<StackResponse> stack(List<String> ids) async {

View File

@@ -6,24 +6,34 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
typedef _ShareFile = ({File file, bool cleanup, String displayName});
final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider), ref.watch(storageRepositoryProvider)),
);
class AssetMediaRepository {
final NativeSyncApi _nativeSyncApi;
final StorageRepository _storageRepository;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._nativeSyncApi);
const AssetMediaRepository(this._nativeSyncApi, this._storageRepository);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -105,9 +115,149 @@ class AssetMediaRepository {
);
}
String _sanitizeFilename(String filename) {
return filename.replaceAll(RegExp(r'[\\/]'), '_');
}
String _getPreviewFilename(BaseAsset asset) {
final sanitizedFilename = _sanitizeFilename(asset.name);
final baseName = p.basenameWithoutExtension(sanitizedFilename);
final fallbackName = asset.remoteId ?? asset.localId ?? 'asset';
return '${baseName.isEmpty ? fallbackName : baseName}-preview.jpg';
}
bool _isCancelled(Completer<void>? cancelCompleter) => cancelCompleter?.isCompleted ?? false;
Future<_ShareFile?> _getLocalOriginalShareFile(BaseAsset asset, String localId) async {
final file = await _storageRepository.getFileForAsset(localId);
if (file == null) {
_log.warning("Local original file not found for sharing: $asset");
return null;
}
return (file: file, cleanup: CurrentPlatform.isIOS, displayName: _sanitizeFilename(asset.name));
}
Future<_ShareFile?> _downloadRemoteShareFile({
required String taskId,
required String url,
required String displayName,
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) async {
final task = DownloadTask(
taskId: taskId,
url: url,
headers: ApiService.getRequestHeaders(),
filename: '$taskId-$displayName',
baseDirectory: BaseDirectory.temporary,
group: kShareDownloadGroup,
updates: Updates.statusAndProgress,
);
final downloader = FileDownloader();
final statusUpdate = await downloader.download(
task,
onProgress: (value) {
if (_isCancelled(cancelCompleter)) {
unawaited(downloader.cancelTaskWithId(taskId));
return;
}
onProgress(value);
},
);
if (_isCancelled(cancelCompleter)) {
return null;
}
if (statusUpdate.status == TaskStatus.complete) {
return (file: File(await task.filePath()), cleanup: true, displayName: displayName);
}
_log.severe("Download for $displayName failed with status ${statusUpdate.status}", statusUpdate.exception);
return null;
}
Future<_ShareFile?> _getRemoteOriginalShareFile(
BaseAsset asset,
String remoteId, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) {
return _downloadRemoteShareFile(
taskId: 'share-original-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
displayName: _sanitizeFilename(asset.name),
cancelCompleter: cancelCompleter,
onProgress: onProgress,
);
}
Future<_ShareFile?> _getRemotePreviewShareFile(
BaseAsset asset,
String remoteId, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) {
return _downloadRemoteShareFile(
taskId: 'share-preview-$remoteId-${DateTime.now().microsecondsSinceEpoch}',
url: getThumbnailUrlForRemoteId(remoteId, type: AssetMediaSize.preview, edited: asset.isEdited),
displayName: _getPreviewFilename(asset),
cancelCompleter: cancelCompleter,
onProgress: onProgress,
);
}
Future<_ShareFile?> _getOriginalShareFile(
BaseAsset asset, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) {
final localId = asset.localId;
if (localId != null && !asset.isEdited) {
return _getLocalOriginalShareFile(asset, localId);
}
final remoteId = asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
return Future.value(null);
}
return _getRemoteOriginalShareFile(asset, remoteId, cancelCompleter: cancelCompleter, onProgress: onProgress);
}
Future<_ShareFile?> _getPreviewShareFile(
BaseAsset asset, {
Completer<void>? cancelCompleter,
required void Function(double progress) onProgress,
}) async {
final remoteId = asset.remoteId;
if (remoteId != null) {
final remotePreview = await _getRemotePreviewShareFile(
asset,
remoteId,
cancelCompleter: cancelCompleter,
onProgress: onProgress,
);
if (remotePreview != null || asset.isEdited) {
return remotePreview;
}
}
final localId = asset.localId;
if (localId != null) {
return _getLocalOriginalShareFile(asset, localId);
}
_log.warning("Asset has no local or remote ID for preview sharing: $asset");
return null;
}
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
@@ -129,75 +279,42 @@ class AssetMediaRepository {
updateProgress();
for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
// if cancelled, delete any temp files created so far
for (final asset in assets) {
if (_isCancelled(cancelCompleter)) {
await _cleanupTempFiles(tempFiles);
return 0;
}
final localId = (asset is LocalAsset)
? asset.id
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
processedAssets++;
updateProgress();
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
} else {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
processedAssets++;
updateProgress();
continue;
}
final shareFile = switch (fileType) {
ShareAssetType.original => await _getOriginalShareFile(
asset,
cancelCompleter: cancelCompleter,
onProgress: updateProgress,
),
ShareAssetType.preview => await _getPreviewShareFile(
asset,
cancelCompleter: cancelCompleter,
onProgress: updateProgress,
),
};
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
final task = DownloadTask(
taskId: taskId,
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
headers: ApiService.getRequestHeaders(),
filename: sanitizedFilename,
baseDirectory: BaseDirectory.temporary,
group: kShareDownloadGroup,
updates: Updates.statusAndProgress,
);
final statusUpdate = await FileDownloader().download(
task,
onProgress: (value) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
unawaited(FileDownloader().cancelTaskWithId(taskId));
return;
}
updateProgress(value);
},
);
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
if (statusUpdate.status == TaskStatus.complete) {
final filePath = await task.filePath();
final file = File(filePath);
tempFiles.add(file);
downloadedXFiles.add(XFile(filePath));
processedAssets++;
updateProgress();
continue;
}
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
processedAssets++;
updateProgress();
if (_isCancelled(cancelCompleter)) {
await _cleanupTempFiles(tempFiles);
return 0;
}
if (shareFile == null) {
processedAssets++;
updateProgress();
continue;
}
downloadedXFiles.add(XFile(shareFile.file.path, name: shareFile.displayName));
if (shareFile.cleanup) {
tempFiles.add(shareFile.file);
}
processedAssets++;
updateProgress();
}
if (downloadedXFiles.isEmpty) {
@@ -205,7 +322,7 @@ class AssetMediaRepository {
return 0;
}
if (cancelCompleter != null && cancelCompleter.isCompleted) {
if (_isCancelled(cancelCompleter)) {
await _cleanupTempFiles(tempFiles);
return 0;
}

View File

@@ -5,9 +5,8 @@ import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -96,7 +95,7 @@ class UploadRepository {
void Function(int bytes, int totalBytes)? onProgress,
required String logContext,
}) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final String savedEndpoint = SessionRepository.instance.session.serverEndpoint!;
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),

View File

@@ -2,9 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
@@ -23,10 +21,8 @@ class AuthGuard extends AutoRouteGuard {
// guards, so we keep this function fully sync and validate the token in
// the background — otherwise a slow validateAccessToken() request would
// block the route transition for as long as the OS-level HTTP timeout.
try {
Store.get(StoreKey.accessToken);
} on StoreKeyNotFoundException catch (_) {
_log.warning('No access token in the store.');
if (SessionRepository.instance.session.accessToken == null) {
_log.warning('No access token in the session.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()]));
return;
@@ -40,7 +36,7 @@ class AuthGuard extends AutoRouteGuard {
if (_validateInFlight) {
return;
}
final token = Store.tryGet(StoreKey.accessToken);
final token = SessionRepository.instance.session.accessToken;
if (token == null) {
return;
}
@@ -50,7 +46,7 @@ class AuthGuard extends AutoRouteGuard {
if (res == null || res.authStatus != true) {
// Token may have changed during validation (user logged out + logged in
// again); only act if it still applies to the current session.
if (Store.tryGet(StoreKey.accessToken) != token) {
if (SessionRepository.instance.session.accessToken != token) {
return;
}
_log.fine('User token is invalid. Redirecting to login');
@@ -61,7 +57,7 @@ class AuthGuard extends AutoRouteGuard {
if (e.code != HttpStatus.unauthorized) {
return;
}
if (Store.tryGet(StoreKey.accessToken) != token) {
if (SessionRepository.instance.session.accessToken != token) {
return;
}
_log.warning("Unauthorized access token.");

View File

@@ -6,15 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -38,6 +38,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
ref.watch(appMetadataRepositoryProvider),
),
);
@@ -51,6 +52,7 @@ class ActionService {
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
final AppMetadataRepository _appMetadataRepository;
const ActionService(
this._assetApiRepository,
@@ -62,6 +64,7 @@ class ActionService {
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
this._appMetadataRepository,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -206,15 +209,24 @@ class ActionService {
return false;
}
// convert dateTime to DateTime object
final parsedDateTime = DateTime.parse(dateTime);
await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime);
await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime);
await applyDateTime(remoteIds, dateTime);
return true;
}
@visibleForTesting
Future<void> applyDateTime(List<String> remoteIds, String dateTime) async {
final parsedDateTime = DateTime.parse(dateTime);
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime)?.group(0);
await _assetApiRepository.updateDateTime(remoteIds, dateTime);
await _remoteAssetRepository.updateDateTime(
remoteIds,
parsedDateTime,
timeZone: offset == null ? null : 'UTC$offset',
);
}
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) {
@@ -272,12 +284,14 @@ class ActionService {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
@@ -307,7 +321,7 @@ class ActionService {
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);

View File

@@ -3,10 +3,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -41,7 +41,7 @@ class ApiService {
// The below line ensures that the api clients are initialized when the service is instantiated
// This is required to avoid late initialization errors when the clients are access before the endpoint is resolved
setEndpoint('');
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
final endpoint = SessionRepository.instance.session.serverEndpoint;
if (endpoint != null && endpoint.isNotEmpty) {
setEndpoint(endpoint);
}
@@ -84,7 +84,7 @@ class ApiService {
setEndpoint(endpoint);
// Save in local database for next startup
await Store.put(StoreKey.serverEndpoint, endpoint);
await SessionRepository.instance.write(SessionKey.serverEndpoint, endpoint);
return endpoint;
}
@@ -173,13 +173,13 @@ class ApiService {
static List<String> getServerUrls() {
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
final serverEndpoint = SessionRepository.instance.session.serverEndpoint;
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final network = SettingsRepository.instance.appConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint.isNotEmpty) {
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
for (final url in network.externalEndpointList) {

View File

@@ -1,26 +0,0 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
final StoreKey<T> storeKey;
final String? hiveKey;
final T defaultValue;
}
class AppSettingsService {
const AppSettingsService();
T getSetting<T>(AppSettingsEnum<T> setting) {
return Store.get(setting.storeKey, setting.defaultValue);
}
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
}

View File

@@ -1,12 +1,12 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -55,7 +55,7 @@ class AuthService {
Future<String> validateServerUrl(String url) async {
final validUrl = await _apiService.resolveAndSetEndpoint(url);
await _apiService.setDeviceInfoHeader();
await Store.put(StoreKey.serverUrl, validUrl);
await SessionRepository.instance.write(SessionKey.serverUrl, validUrl);
return validUrl;
}
@@ -118,8 +118,7 @@ class AuthService {
await _backgroundSyncManager.cancel();
await Future.wait([
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
SessionRepository.instance.clear([SessionKey.accessToken]),
SettingsRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@@ -386,7 +387,7 @@ class BackgroundUploadService {
String? latitude,
String? longitude,
}) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final serverEndpoint = SessionRepository.instance.session.serverEndpoint!;
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
@@ -51,6 +52,8 @@ abstract final class Bootstrap {
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
await SessionRepository.ensureInitialized(drift);
final settingsRepo = await SettingsRepository.ensureInitialized(drift);
await LogService.init(

View File

@@ -1,13 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
ValueNotifier<T> useAppSettingsState<T>(AppSettingsEnum<T> key) {
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));
// Listen to changes to the notifier and update app settings
useValueChanged(notifier.value, (_, __) => Store.put(key.storeKey, notifier.value));
return notifier;
}

View File

@@ -1,9 +1,8 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:openapi/api.dart';
String getOriginalUrlForRemoteId(final String id, {bool edited = true}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited';
return '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/original?edited=$edited';
}
String getThumbnailUrlForRemoteId(
@@ -12,14 +11,15 @@ String getThumbnailUrlForRemoteId(
bool edited = true,
String? thumbhash,
}) {
final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
final url =
'${SessionRepository.instance.session.serverEndpoint!}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
}
String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
return '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/video/playback?';
}
String getFaceThumbnailUrl(final String personId) {
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
return '${SessionRepository.instance.session.serverEndpoint!}/people/$personId/thumbnail';
}

View File

@@ -6,22 +6,29 @@ import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
final metadataRepository = AppMetadataRepository(drift);
final int version = await metadataRepository.get(AppMetadataKey.version);
if (version < 25) {
await _migrateTo25();
@@ -31,26 +38,38 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
await Store.put(StoreKey.version, targetVersion);
if (version < 27) {
await _migrateTo27(drift);
}
if (version < 28) {
await _migrateTo28(drift);
}
if (version < 29) {
await _migrateTo29(drift);
}
await metadataRepository.set(AppMetadataKey.version, kCurrentVersion);
return;
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken);
final accessToken = Store.tryGet(.legacyAccessToken);
if (accessToken == null || accessToken.isEmpty) {
return;
}
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
final serverEndpoint = Store.tryGet(.legacyServerEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint);
final localEndpoint = Store.tryGet(.legacyLocalEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList);
final externalJson = Store.tryGet(.legacyExternalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
@@ -64,7 +83,7 @@ Future<void> _migrateTo25() async {
return;
}
final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, "");
final customHeadersStr = Store.get(.legacyCustomHeaders, "");
final headers = customHeadersStr.isEmpty
? const <String, String>{}
: (jsonDecode(customHeadersStr) as Map).cast<String, String>();
@@ -73,81 +92,109 @@ Future<void> _migrateTo25() async {
}
Future<void> _migrateTo26(Drift drift) async {
final migrator = _StoreMigrator(drift);
await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, SettingsKey.logLevel, LogLevel.values);
final migrator = _StoreMigrator.settings(drift);
await migrator.migrateEnumIndex(.legacyLogLevel, .logLevel, LogLevel.values);
// Theme
await migrator.migrateEnumName(StoreKey.legacyThemeMode, SettingsKey.themeMode, ThemeMode.values);
await migrator.migrateEnumName(StoreKey.legacyPrimaryColor, SettingsKey.themePrimaryColor, ImmichColorPreset.values);
await migrator.migrateBool(StoreKey.legacyDynamicTheme, SettingsKey.themeDynamic);
await migrator.migrateBool(StoreKey.legacyColorfulInterface, SettingsKey.themeColorfulInterface);
await migrator.migrateEnumName(.legacyThemeMode, .themeMode, ThemeMode.values);
await migrator.migrateEnumName(.legacyPrimaryColor, .themePrimaryColor, ImmichColorPreset.values);
await migrator.migrateBool(.legacyDynamicTheme, .themeDynamic);
await migrator.migrateBool(.legacyColorfulInterface, .themeColorfulInterface);
// Cleanup
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(.legacyCleanupKeepAlbumIds);
if (cleanupKeepAlbumIds != null) {
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, SettingsKey.cleanupKeepAlbumIds, ids);
migrator.stage(.legacyCleanupKeepAlbumIds, .cleanupKeepAlbumIds, ids);
}
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, SettingsKey.cleanupKeepFavorites);
await migrator.migrateEnumIndex(
StoreKey.legacyCleanupKeepMediaType,
SettingsKey.cleanupKeepMediaType,
AssetKeepType.values,
);
await migrator.migrateInt(StoreKey.legacyCleanupCutoffDaysAgo, SettingsKey.cleanupCutoffDaysAgo);
await migrator.migrateBool(StoreKey.legacyCleanupDefaultsInitialized, SettingsKey.cleanupDefaultsInitialized);
await migrator.migrateBool(.legacyCleanupKeepFavorites, .cleanupKeepFavorites);
await migrator.migrateEnumIndex(.legacyCleanupKeepMediaType, .cleanupKeepMediaType, AssetKeepType.values);
await migrator.migrateInt(.legacyCleanupCutoffDaysAgo, .cleanupCutoffDaysAgo);
await migrator.migrateBool(.legacyCleanupDefaultsInitialized, .cleanupDefaultsInitialized);
// Map
await migrator.migrateBool(StoreKey.legacyMapShowFavoriteOnly, SettingsKey.mapShowFavoriteOnly);
await migrator.migrateInt(StoreKey.legacyMapRelativeDate, SettingsKey.mapRelativeDate);
await migrator.migrateBool(StoreKey.legacyMapIncludeArchived, SettingsKey.mapIncludeArchived);
await migrator.migrateEnumIndex(StoreKey.legacyMapThemeMode, SettingsKey.mapThemeMode, ThemeMode.values);
await migrator.migrateBool(StoreKey.legacyMapwithPartners, SettingsKey.mapWithPartners);
await migrator.migrateBool(.legacyMapShowFavoriteOnly, .mapShowFavoriteOnly);
await migrator.migrateInt(.legacyMapRelativeDate, .mapRelativeDate);
await migrator.migrateBool(.legacyMapIncludeArchived, .mapIncludeArchived);
await migrator.migrateEnumIndex(.legacyMapThemeMode, .mapThemeMode, ThemeMode.values);
await migrator.migrateBool(.legacyMapwithPartners, .mapWithPartners);
// Timeline
await migrator.migrateInt(StoreKey.legacyTilesPerRow, SettingsKey.timelineTilesPerRow);
await migrator.migrateEnumIndex(
StoreKey.legacyGroupAssetsBy,
SettingsKey.timelineGroupAssetsBy,
GroupAssetsBy.values,
);
await migrator.migrateBool(StoreKey.legacyStorageIndicator, SettingsKey.timelineStorageIndicator);
await migrator.migrateInt(.legacyTilesPerRow, .timelineTilesPerRow);
await migrator.migrateEnumIndex(.legacyGroupAssetsBy, .timelineGroupAssetsBy, GroupAssetsBy.values);
await migrator.migrateBool(.legacyStorageIndicator, .timelineStorageIndicator);
// Image
await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, SettingsKey.imagePreferRemote);
await migrator.migrateBool(StoreKey.legacyLoadOriginal, SettingsKey.imageLoadOriginal);
await migrator.migrateBool(.legacyPreferRemoteImage, .imagePreferRemote);
await migrator.migrateBool(.legacyLoadOriginal, .imageLoadOriginal);
// Viewer
await migrator.migrateBool(StoreKey.legacyLoopVideo, SettingsKey.viewerLoopVideo);
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, SettingsKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, SettingsKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, SettingsKey.viewerTapToNavigate);
await migrator.migrateBool(.legacyLoopVideo, .viewerLoopVideo);
await migrator.migrateBool(.legacyLoadOriginalVideo, .viewerLoadOriginalVideo);
await migrator.migrateBool(.legacyAutoPlayVideo, .viewerAutoPlayVideo);
await migrator.migrateBool(.legacyTapToNavigate, .viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, SettingsKey.networkAutoEndpointSwitching);
await migrator.migrateString(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName);
await migrator.migrateString(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint);
await migrator.migrateBool(.legacyAutoEndpointSwitching, .networkAutoEndpointSwitching);
final preferredWifiName = await migrator.readLegacyStoreString(.legacyPreferredWifiName);
migrator.stage(.legacyPreferredWifiName, .networkPreferredWifiName, preferredWifiName);
final localEndpoint = await migrator.readLegacyStoreString(.legacyLocalEndpoint);
migrator.stage(.legacyLocalEndpoint, .networkLocalEndpoint, localEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
await _migrateAlbumSortMode(migrator);
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, SettingsKey.albumIsReverse);
await migrator.migrateBool(StoreKey.legacyAlbumGridView, SettingsKey.albumIsGrid);
await migrator.migrateBool(.legacySelectedAlbumSortReverse, .albumIsReverse);
await migrator.migrateBool(.legacyAlbumGridView, .albumIsGrid);
// Backup
await migrator.migrateBool(StoreKey.legacyEnableBackup, SettingsKey.backupEnabled);
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadVideos, SettingsKey.backupUseCellularForVideos);
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadPhotos, SettingsKey.backupUseCellularForPhotos);
await migrator.migrateBool(StoreKey.legacyBackupRequireCharging, SettingsKey.backupRequireCharging);
await migrator.migrateInt(StoreKey.legacyBackupTriggerDelay, SettingsKey.backupTriggerDelay);
await migrator.migrateBool(StoreKey.legacySyncAlbums, SettingsKey.backupSyncAlbums);
await migrator.migrateBool(.legacyEnableBackup, .backupEnabled);
await migrator.migrateBool(.legacyUseWifiForUploadVideos, .backupUseCellularForVideos);
await migrator.migrateBool(.legacyUseWifiForUploadPhotos, .backupUseCellularForPhotos);
await migrator.migrateBool(.legacyBackupRequireCharging, .backupRequireCharging);
await migrator.migrateInt(.legacyBackupTriggerDelay, .backupTriggerDelay);
await migrator.migrateBool(.legacySyncAlbums, .backupSyncAlbums);
await migrator.complete();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
Future<void> _migrateTo27(Drift drift) async {
final migrator = _StoreMigrator.session(drift);
await migrator.migrateString(.legacyServerUrl, .serverUrl);
await migrator.migrateString(.legacyAccessToken, .accessToken);
await migrator.migrateString(.legacyServerEndpoint, .serverEndpoint);
await migrator.complete();
await SessionRepository.instance.refresh();
}
Future<void> _migrateTo28(Drift drift) async {
final migrator = _StoreMigrator.settings(drift);
await migrator.migrateBool(.legacyAdvancedTroubleshooting, .advancedTroubleshooting);
await migrator.migrateBool(.legacyEnableHapticFeedback, .advancedEnableHapticFeedback);
await migrator.migrateBool(.legacyReadonlyModeEnabled, .advancedReadonlyModeEnabled);
await migrator.complete();
await SettingsRepository.instance.refresh();
}
Future<void> _migrateTo29(Drift drift) async {
final migrator = _StoreMigrator.appMetadata(drift);
final rawStatus = await migrator.readLegacyStoreString(.legacySyncMigrationStatus);
if (rawStatus != null) {
final decoded = jsonDecode(rawStatus);
final migrations = decoded is List ? decoded.whereType<String>().toList() : <String>[];
migrator.stage(.legacySyncMigrationStatus, .syncMigrationStatus, migrations);
}
await migrator.migrateBool(.legacyManageLocalMediaAndroid, .manageLocalMediaAndroid);
await migrator.complete();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator<SettingsKey> migrator) async {
final raw = await migrator.readLegacyStoreInt(.legacySelectedAlbumSortOrder);
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
if (mode == null) {
return;
}
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, SettingsKey.albumSortMode, mode);
migrator.stage(.legacySelectedAlbumSortOrder, .albumSortMode, mode);
}
Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
Future<void> _migrateExternalEndpointList(_StoreMigrator<SettingsKey> migrator) async {
final raw = await migrator.readLegacyStoreString(.legacyExternalEndpointList);
if (raw == null) {
return;
}
@@ -170,8 +217,8 @@ Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
migrator.stage(StoreKey.legacyExternalEndpointList, SettingsKey.networkExternalEndpointList, urls);
}
Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
Future<void> _migrateCustomHeaders(_StoreMigrator<SettingsKey> migrator) async {
final raw = await migrator.readLegacyStoreString(.legacyCustomHeaders);
if (raw == null) {
return;
}
@@ -193,15 +240,51 @@ Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
migrator.stage(StoreKey.legacyCustomHeaders, SettingsKey.networkCustomHeaders, headers);
}
class _StoreMigrator {
class _StoreMigrator<K extends Enum> {
_StoreMigrator._(this._db, {required this.encode, required this.readDefault, required this.insertRow});
static _StoreMigrator<SettingsKey> settings(Drift db) => _StoreMigrator<SettingsKey>._(
db,
encode: (key, value) => key.encode(value),
readDefault: (key) => defaultConfig.read(key),
insertRow: (batch, name, value) => batch.insert(
db.settingsEntity,
SettingsEntityCompanion(key: Value(name), value: Value(value)),
mode: InsertMode.insertOrReplace,
),
);
static _StoreMigrator<SessionKey> session(Drift db) => _StoreMigrator<SessionKey>._(
db,
encode: (key, value) => key.encode(value),
readDefault: (key) => defaultSession.read(key),
insertRow: (batch, name, value) => batch.insert(
db.sessionEntity,
SessionEntityCompanion(key: Value(name), value: Value(value)),
mode: InsertMode.insertOrReplace,
),
);
static _StoreMigrator<AppMetadataKey> appMetadata(Drift db) => _StoreMigrator<AppMetadataKey>._(
db,
encode: (key, value) => key.encode(value),
readDefault: (_) => null,
insertRow: (batch, name, value) => batch.insert(
db.appMetadataEntity,
AppMetadataEntityCompanion(key: Value(name), value: Value(value)),
mode: InsertMode.insertOrReplace,
),
);
final Drift _db;
final Map<SettingsKey<Object>, Object> _cache = {};
final String Function(K key, Object value) encode;
final Object? Function(K key) readDefault;
final void Function(Batch batch, String name, String? value) insertRow;
final Map<K, Object?> _cache = {};
final List<int> _migratedStoreIds = [];
_StoreMigrator(this._db);
Future<void> migrateEnumIndex<T extends Enum>(StoreKey<int> legacyKey, SettingsKey<T> newKey, List<T> values) async {
final index = await readLegacyStoreInt(legacyKey.id);
Future<void> migrateEnumIndex<T extends Enum>(StoreKey<int> legacyKey, K newKey, List<T> values) async {
final index = await readLegacyStoreInt(legacyKey);
if (index == null) {
return;
}
@@ -215,12 +298,8 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateEnumName<T extends Enum>(
StoreKey<String> legacyKey,
SettingsKey<T> newKey,
List<T> values,
) async {
final name = await readLegacyStoreString(legacyKey.id);
Future<void> migrateEnumName<T extends Enum>(StoreKey<String> legacyKey, K newKey, List<T> values) async {
final name = await readLegacyStoreString(legacyKey);
if (name == null) {
return;
}
@@ -234,19 +313,18 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateBool(StoreKey<bool> legacyKey, SettingsKey<bool> newKey) async {
final intValue = await readLegacyStoreInt(legacyKey.id);
Future<void> migrateBool(StoreKey<bool> legacyKey, K newKey) async {
final intValue = await readLegacyStoreInt(legacyKey);
if (intValue == null) {
return;
}
final boolValue = intValue != 0;
_cache[newKey] = boolValue;
_cache[newKey] = intValue != 0;
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateInt(StoreKey<int> legacyKey, SettingsKey<int> newKey) async {
final intValue = await readLegacyStoreInt(legacyKey.id);
Future<void> migrateInt(StoreKey<int> legacyKey, K newKey) async {
final intValue = await readLegacyStoreInt(legacyKey);
if (intValue == null) {
return;
}
@@ -255,9 +333,9 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateString(StoreKey<String> legacyKey, SettingsKey<String> newKey) async {
final value = await readLegacyStoreString(legacyKey.id);
if (value == null) {
Future<void> migrateString(StoreKey<String> legacyKey, K newKey) async {
final value = await readLegacyStoreString(legacyKey);
if (value == null || value.isEmpty) {
return;
}
@@ -265,7 +343,12 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
void stage<T extends Object>(StoreKey legacyKey, SettingsKey<T> newKey, T value) {
Future<void> migrateNullableString(StoreKey<String> legacyKey, K newKey) async {
_cache[newKey] = await readLegacyStoreString(legacyKey);
_migratedStoreIds.add(legacyKey.id);
}
void stage(StoreKey legacyKey, K newKey, Object? value) {
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
@@ -273,26 +356,24 @@ class _StoreMigrator {
Future<void> complete() async {
await _db.batch((batch) {
for (final entry in _cache.entries) {
if (entry.value == defaultConfig.read(entry.key)) {
if (entry.value == readDefault(entry.key)) {
continue;
}
batch.insert(
_db.settingsEntity,
SettingsEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
mode: InsertMode.insertOrReplace,
);
final value = entry.value;
insertRow(batch, entry.key.name, value == null ? null : encode(entry.key, value));
}
});
await deleteLegacyStoreRows(_migratedStoreIds);
}
Future<String?> readLegacyStoreString(int id) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
Future<String?> readLegacyStoreString(StoreKey key) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(key.id))).getSingleOrNull();
return row?.stringValue;
}
Future<int?> readLegacyStoreInt(int id) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
Future<int?> readLegacyStoreInt(StoreKey key) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(key.id))).getSingleOrNull();
return row?.intValue;
}

View File

@@ -55,8 +55,8 @@ final class None<T> extends Option<T> {
int get hashCode => 0;
}
extension ObjectOptionExtension<T> on T? {
Option<T> toOption() => Option.fromNullable(this);
extension NullableOptionExtension<T> on Option<T>? {
T? patch(T? current) => this == null ? current : this!.unwrapOrNull;
}
extension OptionToOptional<T> on Option<T> {

View File

@@ -24,7 +24,9 @@ import 'package:timezone/timezone.dart';
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
final m = re.firstMatch(timeZone);
if (m != null) {
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
final hours = int.parse(m.group(1) ?? '0');
final minutes = int.parse(m.group(2) ?? '0');
final duration = Duration(hours: hours, minutes: hours.isNegative ? -minutes : minutes);
dt = dt.add(duration);
return (dt, duration);
}

View File

@@ -1,5 +1,4 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) {
@@ -11,7 +10,7 @@ String sanitizeUrl(String url) {
}
String? getServerUrl() {
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUrl = punycodeDecodeUrl(SessionRepository.instance.session.serverEndpoint);
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;

View File

@@ -1,12 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
Widget userAvatar(BuildContext context, UserDto u, {double? radius}) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image";
final url = "${SessionRepository.instance.session.serverEndpoint!}/users/${u.id}/profile-image";
final nameFirstLetter = u.name.isNotEmpty ? u.name[0] : "";
return CircleAvatar(
radius: radius,

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
// ignore: must_be_immutable
class UserCircleAvatar extends ConsumerWidget {
@@ -18,7 +17,7 @@ class UserCircleAvatar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
'${ref.read(sessionProvider).serverEndpoint}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues(
alpha: opacity,

View File

@@ -11,14 +11,14 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
@@ -242,7 +242,8 @@ class LoginForm extends HookConsumerWidget {
}
}
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
Future<bool> isSyncRemoteDeletionsMode() async =>
Platform.isAndroid && await ref.read(appMetadataRepositoryProvider).get(AppMetadataKey.manageLocalMediaAndroid);
login() async {
TextInput.finishAutofillContext();
@@ -257,7 +258,7 @@ class LoginForm extends HookConsumerWidget {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
if (await isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
@@ -345,7 +346,7 @@ class LoginForm extends HookConsumerWidget {
if (isSuccess) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
if (await isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());

View File

@@ -5,15 +5,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@@ -27,8 +27,12 @@ class AdvancedSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final advancedTroubleshooting = useState(ref.read(appConfigProvider).advanced.troubleshooting);
useValueChanged(
advancedTroubleshooting.value,
(_, __) => ref.read(settingsProvider).write(.advancedTroubleshooting, advancedTroubleshooting.value),
);
final manageLocalMediaAndroid = useState(false);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
@@ -37,7 +41,7 @@ class AdvancedSettings extends HookConsumerWidget {
preferRemote.value,
(_, __) => ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value),
);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
final readonlyModeEnabled = useState(ref.read(appConfigProvider).advanced.readonlyModeEnabled);
final logLevel = Level.LEVELS[levelId.value].name;
@@ -57,6 +61,9 @@ class AdvancedSettings extends HookConsumerWidget {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageLocalMediaAndroid.value = await ref
.read(appMetadataRepositoryProvider)
.get(AppMetadataKey.manageLocalMediaAndroid);
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
}
}();
@@ -83,6 +90,9 @@ class AdvancedSettings extends HookConsumerWidget {
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
await ref.read(appMetadataRepositoryProvider).set(AppMetadataKey.manageLocalMediaAndroid, result);
} else {
await ref.read(appMetadataRepositoryProvider).set(AppMetadataKey.manageLocalMediaAndroid, false);
}
},
),

View File

@@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
@@ -20,7 +19,6 @@ class GroupSettings extends HookConsumerWidget {
Future<void> updateAppSettings(GroupAssetsBy groupBy) async {
await ref.read(settingsProvider).write(.timelineGroupAssetsBy, groupBy);
ref.invalidate(appSettingsServiceProvider);
ref.invalidate(timelineServiceProvider);
}

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@@ -32,9 +31,6 @@ class LayoutSettings extends HookConsumerWidget {
maxValue: 6,
minValue: 2,
noDivisons: 4,
onChangeEnd: (value) {
ref.invalidate(appSettingsServiceProvider);
},
),
],
);

View File

@@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart';
@@ -22,7 +21,6 @@ class AssetListSettings extends HookConsumerWidget {
title: 'theme_setting_asset_list_storage_indicator_title'.tr(),
onChanged: (value) {
ref.read(settingsProvider).write(.timelineStorageIndicator, value);
ref.invalidate(appSettingsServiceProvider);
ref.invalidate(settingsProvider);
},
),

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -29,7 +28,6 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
valueNotifier: isOriginal,
title: "setting_image_viewer_original_title".t(context: context),
subtitle: "setting_image_viewer_original_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);

View File

@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@@ -17,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.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/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
@@ -219,7 +219,7 @@ class _SyncStatsCounts extends ConsumerWidget {
final localAlbumService = ref.watch(localAlbumServiceProvider);
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
final memoryService = ref.watch(driftMemoryServiceProvider);
final appSettingsService = ref.watch(appSettingsServiceProvider);
final appMetadataRepository = ref.watch(appMetadataRepositoryProvider);
Future<List<dynamic>> loadCounts() async {
final assetCounts = assetService.getAssetCounts();
@@ -227,8 +227,16 @@ class _SyncStatsCounts extends ConsumerWidget {
final remoteAlbumCounts = remoteAlbumService.getCount();
final memoryCount = memoryService.getCount();
final getLocalHashedCount = assetService.getLocalHashedCount();
final manageLocalMediaAndroid = appMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid);
return await Future.wait([assetCounts, localAlbumCounts, remoteAlbumCounts, memoryCount, getLocalHashedCount]);
return await Future.wait([
assetCounts,
localAlbumCounts,
remoteAlbumCounts,
memoryCount,
getLocalHashedCount,
manageLocalMediaAndroid,
]);
}
return FutureBuilder(
@@ -254,14 +262,15 @@ class _SyncStatsCounts extends ConsumerWidget {
);
}
final assetCounts = snapshot.data![0]! as (int, int);
final assetCounts = snapshot.data![0] as (int, int);
final localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2;
final localAlbumCount = snapshot.data![1]! as int;
final remoteAlbumCount = snapshot.data![2]! as int;
final memoryCount = snapshot.data![3]! as int;
final localHashedCount = snapshot.data![4]! as int;
final localAlbumCount = snapshot.data![1] as int;
final remoteAlbumCount = snapshot.data![2] as int;
final memoryCount = snapshot.data![3] as int;
final localHashedCount = snapshot.data![4] as int;
final manageLocalMediaAndroid = snapshot.data![5] as bool;
return Column(
mainAxisAlignment: MainAxisAlignment.start,
@@ -354,8 +363,7 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
if (CurrentPlatform.isAndroid && manageLocalMediaAndroid) ...[
SettingGroupTitle(title: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {

View File

@@ -2,21 +2,23 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/providers/infrastructure/settings.provider.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/utils/hooks/app_settings_update_hook.dart';
class HapticSetting extends HookConsumerWidget {
const HapticSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hapticFeedbackSetting = useAppSettingsState(AppSettingsEnum.enableHapticFeedback);
final isHapticFeedbackEnabled = useValueNotifier(hapticFeedbackSetting.value);
final isHapticFeedbackEnabled = useState(ref.read(appConfigProvider).advanced.enableHapticFeedback);
useValueChanged(
isHapticFeedbackEnabled.value,
(_, __) => ref.read(settingsProvider).write(.advancedEnableHapticFeedback, isHapticFeedbackEnabled.value),
);
onHapticFeedbackChange(bool isEnabled) {
hapticFeedbackSetting.value = isEnabled;
isHapticFeedbackEnabled.value = isEnabled;
}
return Column(

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/haptic_setting.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/share_setting.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/theme_setting.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
@@ -8,7 +9,7 @@ class PreferenceSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
const preferenceSettings = [ThemeSetting(), HapticSetting()];
const preferenceSettings = [ThemeSetting(), HapticSetting(), ShareSetting()];
return const SettingsSubPageScaffold(settings: preferenceSettings, showDivider: true);
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
class ShareSetting extends HookConsumerWidget {
const ShareSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fileType = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.share.fileType)));
void onChanged(ShareAssetType? value) {
if (value != null) {
fileType.value = value;
ref.read(settingsProvider).write(SettingsKey.shareFileType, value);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(title: context.t.default_share_quality, icon: Icons.ios_share_outlined),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.t.default_quality_subtitle,
style: context.textTheme.bodyMedium!.copyWith(color: context.textTheme.bodyMedium!.color!.withAlpha(215)),
),
),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(title: context.t.share_original, value: ShareAssetType.original),
SettingsRadioGroup(title: context.t.share_preview, value: ShareAssetType.preview),
],
groupBy: fileType.value,
onRadioChanged: onChanged,
),
],
);
}
}

View File

@@ -183,7 +183,12 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
*MaintenanceAdminApi* | [**deleteIntegrityReport**](doc//MaintenanceAdminApi.md#deleteintegrityreport) | **DELETE** /admin/integrity/report/{id} | Delete integrity report item
*MaintenanceAdminApi* | [**detectPriorInstall**](doc//MaintenanceAdminApi.md#detectpriorinstall) | **GET** /admin/maintenance/detect-install | Detect existing install
*MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **GET** /admin/integrity/report | Get integrity report by type
*MaintenanceAdminApi* | [**getIntegrityReportCsv**](doc//MaintenanceAdminApi.md#getintegrityreportcsv) | **GET** /admin/integrity/report/{type}/csv | Export integrity report by type as CSV
*MaintenanceAdminApi* | [**getIntegrityReportFile**](doc//MaintenanceAdminApi.md#getintegrityreportfile) | **GET** /admin/integrity/report/{id}/file | Download flagged file
*MaintenanceAdminApi* | [**getIntegrityReportSummary**](doc//MaintenanceAdminApi.md#getintegrityreportsummary) | **GET** /admin/integrity/summary | Get integrity report summary
*MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
@@ -448,6 +453,10 @@ Class | Method | HTTP request | Description
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
- [IntegrityReport](doc//IntegrityReport.md)
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
- [IntegrityReportResponseDtoItemsInner](doc//IntegrityReportResponseDtoItemsInner.md)
- [IntegrityReportSummaryResponseDto](doc//IntegrityReportSummaryResponseDto.md)
- [JobCreateDto](doc//JobCreateDto.md)
- [JobName](doc//JobName.md)
- [JobSettingsDto](doc//JobSettingsDto.md)
@@ -630,6 +639,9 @@ Class | Method | HTTP request | Description
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
- [SystemConfigImageDto](doc//SystemConfigImageDto.md)
- [SystemConfigIntegrityChecks](doc//SystemConfigIntegrityChecks.md)
- [SystemConfigIntegrityChecksumJob](doc//SystemConfigIntegrityChecksumJob.md)
- [SystemConfigIntegrityJob](doc//SystemConfigIntegrityJob.md)
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
- [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md)
- [SystemConfigLibraryScanDto](doc//SystemConfigLibraryScanDto.md)

View File

@@ -174,6 +174,10 @@ part 'model/facial_recognition_config.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
part 'model/integrity_report.dart';
part 'model/integrity_report_response_dto.dart';
part 'model/integrity_report_response_dto_items_inner.dart';
part 'model/integrity_report_summary_response_dto.dart';
part 'model/job_create_dto.dart';
part 'model/job_name.dart';
part 'model/job_settings_dto.dart';
@@ -356,6 +360,9 @@ part 'model/system_config_faces_dto.dart';
part 'model/system_config_generated_fullsize_image_dto.dart';
part 'model/system_config_generated_image_dto.dart';
part 'model/system_config_image_dto.dart';
part 'model/system_config_integrity_checks.dart';
part 'model/system_config_integrity_checksum_job.dart';
part 'model/system_config_integrity_job.dart';
part 'model/system_config_job_dto.dart';
part 'model/system_config_library_dto.dart';
part 'model/system_config_library_scan_dto.dart';

View File

@@ -1067,7 +1067,9 @@ class AssetsApi {
/// * [String] key:
///
/// * [String] slug:
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
///
/// * [int] xImmichHlsMsn:
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, int? xImmichHlsMsn, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}'
.replaceAll('{filename}', filename)
@@ -1089,6 +1091,10 @@ class AssetsApi {
queryParams.addAll(_queryParams('', 'slug', slug));
}
if (xImmichHlsMsn != null) {
headerParams[r'x-immich-hls-msn'] = parameterToString(xImmichHlsMsn);
}
const contentTypes = <String>[];
@@ -1121,8 +1127,10 @@ class AssetsApi {
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
///
/// * [int] xImmichHlsMsn:
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, int? xImmichHlsMsn, Future<void>? abortTrigger, }) async {
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, xImmichHlsMsn: xImmichHlsMsn, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -16,6 +16,56 @@ class MaintenanceAdminApi {
final ApiClient apiClient;
/// Delete integrity report item
///
/// Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteIntegrityReportWithHttpInfo(String id, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/integrity/report/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Delete integrity report item
///
/// Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)
///
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteIntegrityReport(String id, { Future<void>? abortTrigger, }) async {
final response = await deleteIntegrityReportWithHttpInfo(id, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Detect existing install
///
/// Collect integrity checks and other heuristics about local data.
@@ -65,6 +115,248 @@ class MaintenanceAdminApi {
return null;
}
/// Get integrity report by type
///
/// Get all flagged items by integrity report type
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [IntegrityReport] type (required):
///
/// * [String] cursor:
/// Cursor for pagination
///
/// * [int] limit:
/// Number of items per page
Future<Response> getIntegrityReportWithHttpInfo(IntegrityReport type, { String? cursor, int? limit, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/integrity/report';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (cursor != null) {
queryParams.addAll(_queryParams('', 'cursor', cursor));
}
if (limit != null) {
queryParams.addAll(_queryParams('', 'limit', limit));
}
queryParams.addAll(_queryParams('', 'type', type));
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get integrity report by type
///
/// Get all flagged items by integrity report type
///
/// Parameters:
///
/// * [IntegrityReport] type (required):
///
/// * [String] cursor:
/// Cursor for pagination
///
/// * [int] limit:
/// Number of items per page
Future<IntegrityReportResponseDto?> getIntegrityReport(IntegrityReport type, { String? cursor, int? limit, Future<void>? abortTrigger, }) async {
final response = await getIntegrityReportWithHttpInfo(type, cursor: cursor, limit: limit, abortTrigger: abortTrigger,);
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), 'IntegrityReportResponseDto',) as IntegrityReportResponseDto;
}
return null;
}
/// Export integrity report by type as CSV
///
/// Get all integrity report entries for a given type as a CSV
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [IntegrityReport] type (required):
Future<Response> getIntegrityReportCsvWithHttpInfo(IntegrityReport type, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/integrity/report/{type}/csv'
.replaceAll('{type}', type.toString());
// 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,
abortTrigger: abortTrigger,
);
}
/// Export integrity report by type as CSV
///
/// Get all integrity report entries for a given type as a CSV
///
/// Parameters:
///
/// * [IntegrityReport] type (required):
Future<MultipartFile?> getIntegrityReportCsv(IntegrityReport type, { Future<void>? abortTrigger, }) async {
final response = await getIntegrityReportCsvWithHttpInfo(type, abortTrigger: abortTrigger,);
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;
}
/// Download flagged file
///
/// Download the untracked/broken file if one exists
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getIntegrityReportFileWithHttpInfo(String id, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/integrity/report/{id}/file'
.replaceAll('{id}', id);
// 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,
abortTrigger: abortTrigger,
);
}
/// Download flagged file
///
/// Download the untracked/broken file if one exists
///
/// Parameters:
///
/// * [String] id (required):
Future<MultipartFile?> getIntegrityReportFile(String id, { Future<void>? abortTrigger, }) async {
final response = await getIntegrityReportFileWithHttpInfo(id, abortTrigger: abortTrigger,);
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;
}
/// Get integrity report summary
///
/// Get a count of the items flagged in each integrity report
///
/// Note: This method returns the HTTP [Response].
Future<Response> getIntegrityReportSummaryWithHttpInfo({ Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/integrity/summary';
// 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,
abortTrigger: abortTrigger,
);
}
/// Get integrity report summary
///
/// Get a count of the items flagged in each integrity report
Future<IntegrityReportSummaryResponseDto?> getIntegrityReportSummary({ Future<void>? abortTrigger, }) async {
final response = await getIntegrityReportSummaryWithHttpInfo(abortTrigger: abortTrigger,);
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), 'IntegrityReportSummaryResponseDto',) as IntegrityReportSummaryResponseDto;
}
return null;
}
/// Get maintenance mode status
///
/// Fetch information about the currently running maintenance action.

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