From 2dcb4efc407733fd85d419ce069e1f8b85515371 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:20:05 -0600 Subject: [PATCH 01/11] fix: lock tags column on update (#25435) --- server/src/services/tag.service.spec.ts | 10 +++--- server/src/services/tag.service.ts | 3 +- .../medium/specs/services/tag.service.spec.ts | 33 +++++++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index ff706552a9..a80e6d508b 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -206,15 +206,15 @@ describe(TagService.name, () => { count: 6, }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-1', tags: ['tag-1', 'tag-2'] }, + { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, { lockedPropertiesBehavior: 'append' }, ); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-2', tags: ['tag-1', 'tag-2'] }, + { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, { lockedPropertiesBehavior: 'append' }, ); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-3', tags: ['tag-1', 'tag-2'] }, + { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, { lockedPropertiesBehavior: 'append' }, ); expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ @@ -255,11 +255,11 @@ describe(TagService.name, () => { ]); expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith( - { assetId: 'asset-1', tags: ['tag-1'] }, + { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] }, { lockedPropertiesBehavior: 'append' }, ); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-2', tags: ['tag-1'] }, + { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] }, { lockedPropertiesBehavior: 'append' }, ); expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 3b3000f759..20303421c1 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -16,6 +16,7 @@ import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { updateLockedColumns } from 'src/utils/database'; import { upsertTags } from 'src/utils/tag'; @Injectable() @@ -152,7 +153,7 @@ export class TagService extends BaseService { private async updateTags(assetId: string) { const asset = await this.assetRepository.getById(assetId, { tags: true }); await this.assetRepository.upsertExif( - { assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }, + updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }), { lockedPropertiesBehavior: 'append' }, ); } diff --git a/server/test/medium/specs/services/tag.service.spec.ts b/server/test/medium/specs/services/tag.service.spec.ts index 2ec498e56d..989e4f535f 100644 --- a/server/test/medium/specs/services/tag.service.spec.ts +++ b/server/test/medium/specs/services/tag.service.spec.ts @@ -1,12 +1,15 @@ import { Kysely } from 'kysely'; import { JobStatus } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { DB } from 'src/schema'; import { TagService } from 'src/services/tag.service'; import { upsertTags } from 'src/utils/tag'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -14,8 +17,8 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(TagService, { database: db || defaultDatabase, - real: [TagRepository, AccessRepository], - mock: [LoggingRepository], + real: [AssetRepository, TagRepository, AccessRepository], + mock: [EventRepository, LoggingRepository], }); }; @@ -24,6 +27,32 @@ beforeAll(async () => { }); describe(TagService.name, () => { + describe('addAssets', () => { + it('should lock exif column', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] }); + const authDto = factory.auth({ user }); + + await sut.addAssets(authDto, tag.id, { ids: [asset.id] }); + await expect( + ctx.database + .selectFrom('asset_exif') + .select(['lockedProperties', 'tags']) + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ + lockedProperties: ['tags'], + tags: ['tag-1'], + }); + await expect(ctx.get(TagRepository).getByValue(user.id, 'tag-1')).resolves.toEqual( + expect.objectContaining({ id: tag.id }), + ); + await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); + }); + }); describe('deleteEmptyTags', () => { it('single tag exists, not connected to any assets, and is deleted', async () => { const { sut, ctx } = setup(); From 3304c8efd87e3f8f9131cfecc4ba2515dbe8ed8d Mon Sep 17 00:00:00 2001 From: solluh <42142710+solluh@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:55:44 +0100 Subject: [PATCH 02/11] docs: update README_de_DE.md (#25443) --- readme_i18n/README_de_DE.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index a8685e0902..488b05abcc 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -38,11 +38,6 @@ ภาษาไทย

-## Warnung - -- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung. -- ⚠️ Gehe von möglichen Fehlern und von Änderungen mit Breaking-Changes aus. -- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.** - ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos! > [!NOTE] @@ -62,7 +57,7 @@ ## Demo -Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben. +Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Smartphone-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben. ### Login Daten @@ -93,7 +88,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App | LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja | | Unterstützung für 360-Grad-Bilder | Nein | Ja | | Benutzerdefinierte Speicherstruktur | Ja | Ja | -| Öffentliches Teilen | Nein | Ja | +| Öffentliches Teilen | Ja | Ja | | Archiv und Favoriten | Ja | Ja | | Globale Karte | Ja | Ja | | Partnerfreigabe (Teilen) | Ja | Ja | @@ -103,7 +98,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App | Schreibgeschützte Gallerie | Ja | Ja | | Gestapelte Bilder | Ja | Ja | | Tags | Nein | Ja | -| Ordner-Ansicht | Nein | Ja | +| Ordner-Ansicht | Ja | Ja | ## Übersetzungen From c320146538cfc28f4ccaa7359ca1b6600e51400a Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:43:29 +0100 Subject: [PATCH 03/11] fix: add scoped API permissions to map endpoints (#25423) --- mobile/openapi/lib/model/permission.dart | 6 ++++++ open-api/immich-openapi-specs.json | 4 ++++ open-api/typescript-sdk/src/fetch-client.ts | 2 ++ server/src/controllers/map.controller.ts | 6 +++--- server/src/enum.ts | 3 +++ 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index d5b9bf5086..37aecc8b9c 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -82,6 +82,8 @@ class Permission { static const timelinePeriodRead = Permission._(r'timeline.read'); static const timelinePeriodDownload = Permission._(r'timeline.download'); static const maintenance = Permission._(r'maintenance'); + static const mapPeriodRead = Permission._(r'map.read'); + static const mapPeriodSearch = Permission._(r'map.search'); static const memoryPeriodCreate = Permission._(r'memory.create'); static const memoryPeriodRead = Permission._(r'memory.read'); static const memoryPeriodUpdate = Permission._(r'memory.update'); @@ -238,6 +240,8 @@ class Permission { timelinePeriodRead, timelinePeriodDownload, maintenance, + mapPeriodRead, + mapPeriodSearch, memoryPeriodCreate, memoryPeriodRead, memoryPeriodUpdate, @@ -429,6 +433,8 @@ class PermissionTypeTransformer { case r'timeline.read': return Permission.timelinePeriodRead; case r'timeline.download': return Permission.timelinePeriodDownload; case r'maintenance': return Permission.maintenance; + case r'map.read': return Permission.mapPeriodRead; + case r'map.search': return Permission.mapPeriodSearch; case r'memory.create': return Permission.memoryPeriodCreate; case r'memory.read': return Permission.memoryPeriodRead; case r'memory.update': return Permission.memoryPeriodUpdate; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7f09f7b336..cb0c8f8a67 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6305,6 +6305,7 @@ "state": "Stable" } ], + "x-immich-permission": "map.read", "x-immich-state": "Stable" } }, @@ -6376,6 +6377,7 @@ "state": "Stable" } ], + "x-immich-permission": "map.search", "x-immich-state": "Stable" } }, @@ -18966,6 +18968,8 @@ "timeline.read", "timeline.download", "maintenance", + "map.read", + "map.search", "memory.create", "memory.read", "memory.update", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 97745cc5a1..09a0860539 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5534,6 +5534,8 @@ export enum Permission { TimelineRead = "timeline.read", TimelineDownload = "timeline.download", Maintenance = "maintenance", + MapRead = "map.read", + MapSearch = "map.search", MemoryCreate = "memory.create", MemoryRead = "memory.read", MemoryUpdate = "memory.update", diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index dbd1082561..ae3b56af28 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -8,7 +8,7 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; -import { ApiTag } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -18,7 +18,7 @@ export class MapController { constructor(private service: MapService) {} @Get('markers') - @Authenticated() + @Authenticated({ permission: Permission.MapRead }) @Endpoint({ summary: 'Retrieve map markers', description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.', @@ -28,8 +28,8 @@ export class MapController { return this.service.getMapMarkers(auth, options); } - @Authenticated() @Get('reverse-geocode') + @Authenticated({ permission: Permission.MapSearch }) @HttpCode(HttpStatus.OK) @Endpoint({ summary: 'Reverse geocode coordinates', diff --git a/server/src/enum.ts b/server/src/enum.ts index 8a7e1dc789..5a0f6bdbe0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -160,6 +160,9 @@ export enum Permission { Maintenance = 'maintenance', + MapRead = 'map.read', + MapSearch = 'map.search', + MemoryCreate = 'memory.create', MemoryRead = 'memory.read', MemoryUpdate = 'memory.update', From 7cbfc12e0dc9a8e1eebb04ce3561c2124c81d4ba Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 22 Jan 2026 06:44:08 -0600 Subject: [PATCH 04/11] chore: use context menu for user table (#25428) * chore: use context menu for user table * chore: reorder columns --------- Co-authored-by: Jason Rasmussen --- web/src/lib/services/user-admin.service.ts | 9 +++++- .../routes/admin/users/(list)/+layout.svelte | 32 ++++++++++++------- .../routes/admin/users/[id]/+layout.svelte | 4 +-- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts index 71d20bd9ce..233eb88657 100644 --- a/web/src/lib/services/user-admin.service.ts +++ b/web/src/lib/services/user-admin.service.ts @@ -23,6 +23,7 @@ import { import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiDeleteRestore, + mdiInformationOutline, mdiLockReset, mdiLockSmart, mdiPencilOutline, @@ -46,6 +47,12 @@ export const getUserAdminsActions = ($t: MessageFormatter) => { }; export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => { + const Detail: ActionItem = { + icon: mdiInformationOutline, + title: $t('details'), + onAction: () => goto(Route.viewUser(user)), + }; + const Update: ActionItem = { icon: mdiPencilOutline, title: $t('edit'), @@ -92,7 +99,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons onAction: () => handleResetPinCodeUserAdmin(user), }; - return { Update, Delete, Restore, ResetPassword, ResetPinCode }; + return { Detail, Update, Delete, Restore, ResetPassword, ResetPinCode }; }; export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { diff --git a/web/src/routes/admin/users/(list)/+layout.svelte b/web/src/routes/admin/users/(list)/+layout.svelte index 368ce5ed18..b895e72de5 100644 --- a/web/src/routes/admin/users/(list)/+layout.svelte +++ b/web/src/routes/admin/users/(list)/+layout.svelte @@ -1,15 +1,18 @@ @@ -68,16 +76,18 @@ - {$t('email')} - {$t('name')} + {$t('name')} + {$t('email')} {$t('has_quota')} {#each users as user (user.id)} - {user.email} - {user.name} + + {user.name} + + {user.email}
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} @@ -88,7 +98,7 @@
- +
{/each} diff --git a/web/src/routes/admin/users/[id]/+layout.svelte b/web/src/routes/admin/users/[id]/+layout.svelte index 92d29aa5e2..61fd184303 100644 --- a/web/src/routes/admin/users/[id]/+layout.svelte +++ b/web/src/routes/admin/users/[id]/+layout.svelte @@ -198,8 +198,8 @@ })} >

{$t('storage')}

-
-
+
+
{/if} From 55477a8a1afb0062697ae528eb38b283666e6f63 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 22 Jan 2026 16:53:14 +0100 Subject: [PATCH 05/11] chore: revert mise-action bump (#25451) --- .github/workflows/docs-deploy.yml | 2 +- .github/workflows/docs-destroy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 1933b9d572..8c0bf76f30 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -131,7 +131,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 + uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 - name: Load parameters id: parameters diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 80cc17d32b..a7d068cb43 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -29,7 +29,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 + uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 - name: Destroy Docs Subdomain env: From 78f400305b4e374722710f73052f98db7ab5b0fa Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:07:05 +0100 Subject: [PATCH 06/11] fix(web): don't show ocr button on panoramas (#25450) --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 9e3c121024..91dc041754 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -431,6 +431,7 @@ const showOcrButton = $derived( $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && + !(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') && !isShowEditor && ocrManager.hasOcrData, ); From 945f7fb9ea43867bddf8e203af93b769bed56233 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:38:13 -0500 Subject: [PATCH 07/11] chore(deps): update dependency lodash-es to v4.17.23 [security] (#25453) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59b4846bff..1776c56d7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: version: 1.20.1 lodash-es: specifier: ^4.17.21 - version: 4.17.22 + version: 4.17.23 micromatch: specifier: ^4.0.8 version: 4.0.8 @@ -802,7 +802,7 @@ importers: version: 4.1.0 lodash-es: specifier: ^4.17.21 - version: 4.17.22 + version: 4.17.23 luxon: specifier: ^3.4.4 version: 3.7.2 @@ -8916,8 +8916,8 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - lodash-es@4.17.22: - resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -19332,7 +19332,7 @@ snapshots: chevrotain-allstar@0.3.1(chevrotain@11.0.3): dependencies: chevrotain: 11.0.3 - lodash-es: 4.17.22 + lodash-es: 4.17.23 chevrotain@11.0.3: dependencies: @@ -20027,7 +20027,7 @@ snapshots: dagre-d3-es@7.0.13: dependencies: d3: 7.9.0 - lodash-es: 4.17.22 + lodash-es: 4.17.23 data-urls@3.0.2: dependencies: @@ -22376,7 +22376,7 @@ snapshots: lodash-es@4.17.21: {} - lodash-es@4.17.22: {} + lodash-es@4.17.23: {} lodash.camelcase@4.3.0: {} @@ -22810,7 +22810,7 @@ snapshots: dompurify: 3.3.1 katex: 0.16.27 khroma: 2.1.0 - lodash-es: 4.17.22 + lodash-es: 4.17.23 marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 @@ -25714,7 +25714,7 @@ snapshots: json-source-map: 0.6.1 jsonpath-plus: 10.3.0 jsonrepair: 3.13.1 - lodash-es: 4.17.22 + lodash-es: 4.17.23 memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 From 4bd01b70ff4f3b128b286b0424efb79792c64672 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 22 Jan 2026 12:41:01 -0600 Subject: [PATCH 08/11] fix: asset edit sequence (#25457) --- server/src/queries/asset.edit.repository.sql | 2 ++ server/src/repositories/asset-edit.repository.ts | 9 +++++---- .../1769105700133-AddAssetEditSequence.ts | 14 ++++++++++++++ server/src/schema/tables/asset-edit.table.ts | 5 +++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts diff --git a/server/src/queries/asset.edit.repository.sql b/server/src/queries/asset.edit.repository.sql index d11bc7fe70..0cf62882db 100644 --- a/server/src/queries/asset.edit.repository.sql +++ b/server/src/queries/asset.edit.repository.sql @@ -15,3 +15,5 @@ from "asset_edit" where "assetId" = $1 +order by + "sequence" asc diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts index fdfbc4e1d8..088cb1ccff 100644 --- a/server/src/repositories/asset-edit.repository.ts +++ b/server/src/repositories/asset-edit.repository.ts @@ -12,14 +12,14 @@ export class AssetEditRepository { @GenerateSql({ params: [DummyValue.UUID], }) - async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { - return await this.db.transaction().execute(async (trx) => { + replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { + return this.db.transaction().execute(async (trx) => { await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); if (edits.length > 0) { return trx .insertInto('asset_edit') - .values(edits.map((edit) => ({ assetId, ...edit }))) + .values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit }))) .returning(['action', 'parameters']) .execute() as Promise; } @@ -31,11 +31,12 @@ export class AssetEditRepository { @GenerateSql({ params: [DummyValue.UUID], }) - async getAll(assetId: string): Promise { + getAll(assetId: string): Promise { return this.db .selectFrom('asset_edit') .select(['action', 'parameters']) .where('assetId', '=', assetId) + .orderBy('sequence', 'asc') .execute() as Promise; } } diff --git a/server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts b/server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts new file mode 100644 index 0000000000..40c1723cd6 --- /dev/null +++ b/server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts @@ -0,0 +1,14 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DELETE FROM "asset_edit";`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db); + await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index ad0b443b69..886b62dc0b 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -9,6 +9,7 @@ import { Generated, PrimaryGeneratedColumn, Table, + Unique, } from 'src/sql-tools'; @Table('asset_edit') @@ -19,6 +20,7 @@ import { referencingOldTableAs: 'deleted_edit', when: 'pg_trigger_depth() = 0', }) +@Unique({ columns: ['assetId', 'sequence'] }) export class AssetEditTable { @PrimaryGeneratedColumn() id!: Generated; @@ -31,4 +33,7 @@ export class AssetEditTable { @Column({ type: 'jsonb' }) parameters!: AssetEditActionParameter[T]; + + @Column({ type: 'integer' }) + sequence!: number; } From dd72c32c60e4459b9daba8cd57d0028cc8524c61 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 22 Jan 2026 13:44:00 -0500 Subject: [PATCH 09/11] feat: rename parallel tests to ui, split test step into: [e2e, ui] (#25439) --- .github/workflows/test.yml | 7 ++++++- e2e/playwright.config.ts | 14 +++++++------- ...arallel-e2e-spec.ts => asset-viewer.ui-spec.ts} | 0 ...ne.parallel-e2e-spec.ts => timeline.ui-spec.ts} | 0 4 files changed, 13 insertions(+), 8 deletions(-) rename e2e/src/web/specs/asset-viewer/{asset-viewer.parallel-e2e-spec.ts => asset-viewer.ui-spec.ts} (100%) rename e2e/src/web/specs/timeline/{timeline.parallel-e2e-spec.ts => timeline.ui-spec.ts} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2aed8c6da2..fcd0fd8d5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -502,7 +502,12 @@ jobs: - name: Run e2e tests (web) env: CI: true - run: npx playwright test + run: npx playwright test --project=chromium + if: ${{ !cancelled() }} + - name: Run ui tests (web) + env: + CI: true + run: npx playwright test --project=ui if: ${{ !cancelled() }} - name: Archive test results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 8b7f289921..58f5997343 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = { testMatch: /.*\.e2e-spec\.ts/, workers: 1, }, - // { - // name: 'parallel tests', - // use: { ...devices['Desktop Chrome'] }, - // testMatch: /.*\.parallel-e2e-spec\.ts/, - // fullyParallel: true, - // workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), - // }, + { + name: 'ui', + use: { ...devices['Desktop Chrome'] }, + testMatch: /.*\.ui-spec\.ts/, + fullyParallel: true, + workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), + }, // { // name: 'firefox', diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts similarity index 100% rename from e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts rename to e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.ui-spec.ts similarity index 100% rename from e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts rename to e2e/src/web/specs/timeline/timeline.ui-spec.ts From bccad2940e6c257a694c2b67022904e73328da0a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 23 Jan 2026 03:16:30 +0530 Subject: [PATCH 10/11] fix: incorrect asset viewer scale on image frame update (#25430) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../asset_viewer/asset_viewer.page.dart | 13 +++++------- .../src/controller/photo_view_controller.dart | 10 ++++++++++ .../photo_view/src/photo_view_wrappers.dart | 20 +++++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 38b9c54a3e..e89a41481f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -118,7 +118,6 @@ class _AssetViewerState extends ConsumerState { bool dragInProgress = false; bool shouldPopOnDrag = false; bool assetReloadRequested = false; - double? initialScale; double previousExtent = _kBottomSheetMinimumExtent; Offset dragDownPosition = Offset.zero; int totalAssets = 0; @@ -264,7 +263,6 @@ class _AssetViewerState extends ConsumerState { (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); controller.position = Offset(0, -verticalOffset); // Apply the zoom effect when the bottom sheet is showing - initialScale = controller.scale; controller.scale = (controller.scale ?? 1.0) + 0.01; } } @@ -316,7 +314,7 @@ class _AssetViewerState extends ConsumerState { hasDraggedDown = null; viewController?.animateMultiple( position: initialPhotoViewState.position, - scale: initialPhotoViewState.scale, + scale: viewController?.initialScale ?? initialPhotoViewState.scale, rotation: initialPhotoViewState.rotation, ); ref.read(assetViewerProvider.notifier).setOpacity(255); @@ -366,8 +364,9 @@ class _AssetViewerState extends ConsumerState { final maxScaleDistance = ctx.height * 0.5; final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); double? updatedScale; - if (initialPhotoViewState.scale != null) { - updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); + double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale; + if (initialScale != null) { + updatedScale = initialScale * (1.0 - scaleReduction); } final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); @@ -481,8 +480,6 @@ class _AssetViewerState extends ConsumerState { void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { ref.read(assetViewerProvider.notifier).setBottomSheet(true); - initialScale = viewController?.scale; - // viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01); previousExtent = _kBottomSheetMinimumExtent; sheetCloseController = showBottomSheet( context: ctx, @@ -504,7 +501,7 @@ class _AssetViewerState extends ConsumerState { void _handleSheetClose() { viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: initialScale); + viewController?.updateMultiple(scale: viewController?.initialScale); ref.read(assetViewerProvider.notifier).setBottomSheet(false); sheetCloseController = null; shouldPopOnDrag = false; diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart index 2c8b406385..b9475a9ee2 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart'; +import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart'; /// The interface in which controllers will be implemented. /// @@ -62,6 +63,9 @@ abstract class PhotoViewControllerBase { /// The scale factor to transform the child (image or a customChild). late double? scale; + double? get initialScale; + ScaleBoundaries? scaleBoundaries; + /// Nevermind this method :D, look away void setScaleInvisibly(double? scale); @@ -141,6 +145,9 @@ class PhotoViewController implements PhotoViewControllerBase _outputCtrl; + @override + ScaleBoundaries? scaleBoundaries; + late void Function(Offset)? _animatePosition; late void Function(double)? _animateScale; late void Function(double)? _animateRotation; @@ -311,4 +318,7 @@ class PhotoViewController implements PhotoViewControllerBase scaleBoundaries?.initialScale ?? initial.scale; } diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index a2ad04e6b5..cd70745703 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -108,6 +108,17 @@ class _ImageWrapperState extends State { } } + // Should be called only when _imageSize is not null + ScaleBoundaries get scaleBoundaries { + return ScaleBoundaries( + widget.minScale ?? 0.0, + widget.maxScale ?? double.infinity, + widget.initialScale ?? PhotoViewComputedScale.contained, + widget.outerSize, + _imageSize!, + ); + } + // retrieve image from the provider void _resolveImage() { final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration()); @@ -133,6 +144,7 @@ class _ImageWrapperState extends State { _lastStack = null; _didLoadSynchronously = synchronousCall; + widget.controller.scaleBoundaries = scaleBoundaries; } synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB); @@ -204,14 +216,6 @@ class _ImageWrapperState extends State { ); } - final scaleBoundaries = ScaleBoundaries( - widget.minScale ?? 0.0, - widget.maxScale ?? double.infinity, - widget.initialScale ?? PhotoViewComputedScale.contained, - widget.outerSize, - _imageSize!, - ); - return PhotoViewCore( imageProvider: widget.imageProvider, backgroundDecoration: widget.backgroundDecoration, From 9b2939d77882823a71d8ac049345a6e186915e18 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 23 Jan 2026 03:21:48 +0530 Subject: [PATCH 11/11] fix(mobile): bring back map settings (#25448) * fix(mobile): bring back map settings * chore: styling --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/lib/domain/models/events.model.dart | 5 + mobile/lib/domain/services/map.service.dart | 4 +- .../lib/domain/services/timeline.service.dart | 5 +- .../repositories/map.repository.dart | 25 ++++- .../repositories/timeline.repository.dart | 67 ++++++++++--- .../presentation/pages/drift_map.page.dart | 30 +++++- .../bottom_sheet/map_bottom_sheet.widget.dart | 11 ++- .../presentation/widgets/map/map.state.dart | 94 +++++++++++++++++-- .../presentation/widgets/map/map.widget.dart | 22 ++++- .../widgets/map/map_settings_sheet.dart | 61 ++++++++++++ .../infrastructure/map.provider.dart | 12 ++- .../map_settings/map_settings_list_tile.dart | 3 +- .../map_settings_time_dropdown.dart | 76 ++++++++------- .../map/map_settings/map_theme_picker.dart | 8 +- 14 files changed, 343 insertions(+), 80 deletions(-) create mode 100644 mobile/lib/presentation/widgets/map/map_settings_sheet.dart diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index b3ab756414..fc9cebc80f 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -30,3 +30,8 @@ class MultiSelectToggleEvent extends Event { final bool isEnabled; const MultiSelectToggleEvent(this.isEnabled); } + +// Map Events +class MapMarkerReloadEvent extends Event { + const MapMarkerReloadEvent(); +} diff --git a/mobile/lib/domain/services/map.service.dart b/mobile/lib/domain/services/map.service.dart index 8c50a5aaeb..6c64e2817e 100644 --- a/mobile/lib/domain/services/map.service.dart +++ b/mobile/lib/domain/services/map.service.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/models/map.model.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; typedef MapMarkerSource = Future> Function(LatLngBounds? bounds); @@ -11,7 +12,8 @@ class MapFactory { const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository; - MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId)); + MapService remote(List ownerIds, TimelineMapOptions options) => + MapService(_mapRepository.remote(ownerIds, options)); } class MapService { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index e866a965c4..61e114762c 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; typedef TimelineAssetSource = Future> Function(int index, int count); @@ -82,8 +81,8 @@ class TimelineFactory { TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); - TimelineService map(String userId, LatLngBounds bounds) => - TimelineService(_timelineRepository.map(userId, bounds, groupBy)); + TimelineService map(List userIds, TimelineMapOptions options) => + TimelineService(_timelineRepository.map(userIds, options, groupBy)); } class TimelineService { diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart index 9b8cdcc19d..95e42337fc 100644 --- a/mobile/lib/infrastructure/repositories/map.repository.dart +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/map.service.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class DriftMapRepository extends DriftDatabaseRepository { @@ -12,9 +13,27 @@ class DriftMapRepository extends DriftDatabaseRepository { const DriftMapRepository(super._db) : _db = _db; - MapQuery remote(String ownerId) => _mapQueryBuilder( - assetFilter: (row) => - row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), + MapQuery remote(List ownerIds, TimelineMapOptions options) => _mapQueryBuilder( + assetFilter: (row) { + Expression condition = + row.deletedAt.isNull() & + row.ownerId.isIn(ownerIds) & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.timeline.index, + if (options.includeArchived) AssetVisibility.archive.index, + ]); + + if (options.onlyFavorites) { + condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); + } + + if (options.relativeDays != 0) { + final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); + } + + return condition; + }, ); MapQuery _mapQueryBuilder({Expression Function($RemoteAssetEntityTable row)? assetFilter}) { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index f57ef04b07..b0548bdd28 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -15,6 +15,22 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:stream_transform/stream_transform.dart'; +class TimelineMapOptions { + final LatLngBounds bounds; + final bool onlyFavorites; + final bool includeArchived; + final bool withPartners; + final int relativeDays; + + const TimelineMapOptions({ + required this.bounds, + this.onlyFavorites = false, + this.includeArchived = false, + this.withPartners = false, + this.relativeDays = 0, + }); +} + class DriftTimelineRepository extends DriftDatabaseRepository { final Drift _db; @@ -467,15 +483,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } - TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => ( - bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy), - assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count), + TimelineQuery map(List userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => ( + bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy), + assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count), origin: TimelineOrigin.map, ); Stream> _watchMapBucket( - String userId, - LatLngBounds bounds, { + List userId, + TimelineMapOptions options, { GroupAssetsBy groupBy = GroupAssetsBy.day, }) { if (groupBy == GroupAssetsBy.none) { @@ -496,14 +512,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ), ]) ..where( - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.remoteExifEntity.inBounds(bounds) & - _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.ownerId.isIn(userId) & + _db.remoteExifEntity.inBounds(options.bounds) & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.timeline.index, + if (options.includeArchived) AssetVisibility.archive.index, + ]) & _db.remoteAssetEntity.deletedAt.isNull(), ) ..groupBy([dateExp]) ..orderBy([OrderingTerm.desc(dateExp)]); + if (options.onlyFavorites) { + query.where(_db.remoteAssetEntity.isFavorite.equals(true)); + } + + if (options.relativeDays != 0) { + final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); + } + return query.map((row) { final timeline = row.read(dateExp)!.truncateDate(groupBy); final assetCount = row.read(assetCountExp)!; @@ -512,8 +540,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } Future> _getMapBucketAssets( - String userId, - LatLngBounds bounds, { + List userId, + TimelineMapOptions options, { required int offset, required int count, }) { @@ -526,13 +554,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ), ]) ..where( - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.remoteExifEntity.inBounds(bounds) & - _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.ownerId.isIn(userId) & + _db.remoteExifEntity.inBounds(options.bounds) & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.timeline.index, + if (options.includeArchived) AssetVisibility.archive.index, + ]) & _db.remoteAssetEntity.deletedAt.isNull(), ) ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) ..limit(count, offset: offset); + + if (options.onlyFavorites) { + query.where(_db.remoteAssetEntity.isFavorite.equals(true)); + } + + if (options.relativeDays != 0) { + final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); + } + return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } diff --git a/mobile/lib/presentation/pages/drift_map.page.dart b/mobile/lib/presentation/pages/drift_map.page.dart index de8dde7714..96384c97e5 100644 --- a/mobile/lib/presentation/pages/drift_map.page.dart +++ b/mobile/lib/presentation/pages/drift_map.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/map/map.widget.dart'; +import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -10,6 +11,16 @@ class DriftMapPage extends StatelessWidget { const DriftMapPage({super.key, this.initialLocation}); + void onSettingsPressed(BuildContext context) { + showModalBottomSheet( + elevation: 0.0, + showDragHandle: true, + isScrollControlled: true, + context: context, + builder: (_) => const DriftMapSettingsSheet(), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -18,8 +29,8 @@ class DriftMapPage extends StatelessWidget { children: [ DriftMap(initialLocation: initialLocation), Positioned( - left: 16, - top: 60, + left: 20, + top: 70, child: IconButton.filled( color: Colors.white, onPressed: () => context.pop(), @@ -32,6 +43,21 @@ class DriftMapPage extends StatelessWidget { ), ), ), + Positioned( + right: 20, + top: 70, + child: IconButton.filled( + color: Colors.white, + onPressed: () => onSettingsPressed(context), + icon: const Icon(Icons.more_vert_rounded), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(8), + backgroundColor: Colors.indigo, + shadowColor: Colors.black26, + elevation: 4, + ), + ), + ), ], ), ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart index ac3772a02b..d7ef604718 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart @@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget { Widget build(BuildContext context) { return BaseBottomSheet( initialChildSize: 0.25, - maxChildSize: 0.9, + maxChildSize: 0.75, shouldCloseOnMinExtent: false, resizeOnScroll: false, actions: [], @@ -38,8 +38,13 @@ class _ScopedMapTimeline extends StatelessWidget { throw Exception('User must be logged in to access archive'); } - final bounds = ref.watch(mapStateProvider).bounds; - final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds); + final users = ref.watch(mapStateProvider).withPartners + ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] + : [user.id]; + + final timelineService = ref + .watch(timelineFactoryProvider) + .map(users, ref.watch(mapStateProvider).toOptions()); ref.onDispose(timelineService.dispose); return timelineService; }), diff --git a/mobile/lib/presentation/widgets/map/map.state.dart b/mobile/lib/presentation/widgets/map/map.state.dart index b849f954ae..bfd3011050 100644 --- a/mobile/lib/presentation/widgets/map/map.state.dart +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -1,11 +1,30 @@ +import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; +import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class MapState { + final ThemeMode themeMode; final LatLngBounds bounds; + final bool onlyFavorites; + final bool includeArchived; + final bool withPartners; + final int relativeDays; - const MapState({required this.bounds}); + const MapState({ + this.themeMode = ThemeMode.system, + required this.bounds, + this.onlyFavorites = false, + this.includeArchived = false, + this.withPartners = false, + this.relativeDays = 0, + }); @override bool operator ==(covariant MapState other) { @@ -15,9 +34,31 @@ class MapState { @override int get hashCode => bounds.hashCode; - MapState copyWith({LatLngBounds? bounds}) { - return MapState(bounds: bounds ?? this.bounds); + MapState copyWith({ + LatLngBounds? bounds, + ThemeMode? themeMode, + bool? onlyFavorites, + bool? includeArchived, + bool? withPartners, + int? relativeDays, + }) { + return MapState( + bounds: bounds ?? this.bounds, + themeMode: themeMode ?? this.themeMode, + onlyFavorites: onlyFavorites ?? this.onlyFavorites, + includeArchived: includeArchived ?? this.includeArchived, + withPartners: withPartners ?? this.withPartners, + relativeDays: relativeDays ?? this.relativeDays, + ); } + + TimelineMapOptions toOptions() => TimelineMapOptions( + bounds: bounds, + onlyFavorites: onlyFavorites, + includeArchived: includeArchived, + withPartners: withPartners, + relativeDays: relativeDays, + ); } class MapStateNotifier extends Notifier { @@ -31,11 +72,50 @@ class MapStateNotifier extends Notifier { return true; } + 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); + } + + void switchFavoriteOnly(bool isFavoriteOnly) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly); + state = state.copyWith(onlyFavorites: isFavoriteOnly); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + void switchIncludeArchived(bool isIncludeArchived) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived); + state = state.copyWith(includeArchived: isIncludeArchived); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + void switchWithPartners(bool isWithPartners) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners); + state = state.copyWith(withPartners: isWithPartners); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + void setRelativeTime(int relativeDays) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays); + state = state.copyWith(relativeDays: relativeDays); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + @override - MapState build() => MapState( - // TODO: set default bounds - bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), - ); + MapState build() { + final appSettingsService = ref.read(appSettingsServiceProvider); + return MapState( + themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)], + onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly), + includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived), + withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners), + relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate), + bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), + ); + } } // This provider watches the markers from the map service and serves the markers. diff --git a/mobile/lib/presentation/widgets/map/map.widget.dart b/mobile/lib/presentation/widgets/map/map.widget.dart index 17dcffdade..72f4e8bda6 100644 --- a/mobile/lib/presentation/widgets/map/map.widget.dart +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -51,11 +53,19 @@ class _DriftMapState extends ConsumerState { final _reloadMutex = AsyncMutex(); final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2)); final ValueNotifier bottomSheetOffset = ValueNotifier(0.25); + StreamSubscription? _eventSubscription; + + @override + void initState() { + super.initState(); + _eventSubscription = EventStream.shared.listen(_onEvent); + } @override void dispose() { _debouncer.dispose(); bottomSheetOffset.dispose(); + _eventSubscription?.cancel(); super.dispose(); } @@ -63,6 +73,8 @@ class _DriftMapState extends ConsumerState { mapController = controller; } + void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true)); + Future onMapReady() async { final controller = mapController; if (controller == null) { @@ -98,7 +110,7 @@ class _DriftMapState extends ConsumerState { ); } - _debouncer.run(setBounds); + _debouncer.run(() => setBounds(forceReload: true)); controller.addListener(onMapMoved); } @@ -110,7 +122,7 @@ class _DriftMapState extends ConsumerState { _debouncer.run(setBounds); } - Future setBounds() async { + Future setBounds({bool forceReload = false}) async { final controller = mapController; if (controller == null || !mounted) { return; @@ -127,7 +139,7 @@ class _DriftMapState extends ConsumerState { final bounds = await controller.getVisibleRegion(); unawaited( _reloadMutex.run(() async { - if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) { + if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) { final markers = await ref.read(mapMarkerProvider(bounds).future); await reloadMarkers(markers); } @@ -203,7 +215,7 @@ class _Map extends StatelessWidget { onMapCreated: onMapCreated, onStyleLoadedCallback: onMapReady, attributionButtonPosition: AttributionButtonPosition.topRight, - attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72), + attributionButtonMargins: const Point(8, kToolbarHeight), ), ), ); @@ -244,7 +256,7 @@ class _DynamicMyLocationButton extends StatelessWidget { valueListenable: bottomSheetOffset, builder: (context, offset, child) { return Positioned( - right: 16, + right: 20, bottom: context.height * (offset - 0.02) + context.padding.bottom, child: AnimatedOpacity( opacity: offset < 0.8 ? 1 : 0, diff --git a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart new file mode 100644 index 0000000000..c581dd6292 --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart'; + +class DriftMapSettingsSheet extends HookConsumerWidget { + const DriftMapSettingsSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mapState = ref.watch(mapStateProvider); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + builder: (ctx, scrollController) => SingleChildScrollView( + controller: scrollController, + child: Card( + elevation: 0.0, + shadowColor: Colors.transparent, + color: Colors.transparent, + margin: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + MapThemePicker( + themeMode: mapState.themeMode, + onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode), + ), + const Divider(height: 30, thickness: 1), + MapSettingsListTile( + title: "map_settings_only_show_favorites".t(context: context), + selected: mapState.onlyFavorites, + onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly), + ), + MapSettingsListTile( + title: "map_settings_include_show_archived".t(context: context), + selected: mapState.includeArchived, + onChanged: (includeArchive) => + ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive), + ), + MapSettingsListTile( + title: "map_settings_include_show_partners".t(context: context), + selected: mapState.withPartners, + onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), + ), + MapTimeDropDown( + relativeTime: mapState.relativeDays, + onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/map.provider.dart b/mobile/lib/providers/infrastructure/map.provider.dart index e774cec756..d9d261521e 100644 --- a/mobile/lib/providers/infrastructure/map.provider.dart +++ b/mobile/lib/providers/infrastructure/map.provider.dart @@ -1,7 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/domain/services/map.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; final mapRepositoryProvider = Provider((ref) => DriftMapRepository(ref.watch(driftProvider))); @@ -13,7 +15,11 @@ final mapServiceProvider = Provider( throw Exception('User must be logged in to access map'); } - final mapService = ref.watch(mapFactoryProvider).remote(user.id); + final users = ref.watch(mapStateProvider).withPartners + ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] + : [user.id]; + + final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions()); return mapService; }, // Empty dependencies to inform the framework that this provider diff --git a/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart b/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart index e97875fd90..762c402def 100644 --- a/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart +++ b/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart @@ -1,4 +1,3 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -14,7 +13,7 @@ class MapSettingsListTile extends StatelessWidget { Widget build(BuildContext context) { return SwitchListTile.adaptive( activeThumbColor: context.primaryColor, - title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(), + title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)), value: selected, onChanged: onChanged, ); diff --git a/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart b/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart index b601887e1e..2a4dacaff7 100644 --- a/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart +++ b/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart @@ -1,5 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; class MapTimeDropDown extends StatelessWidget { final int relativeTime; @@ -11,41 +13,47 @@ class MapTimeDropDown extends StatelessWidget { Widget build(BuildContext context) { final now = DateTime.now(); - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)), - ), - LayoutBuilder( - builder: (_, constraints) => DropdownMenu( - width: constraints.maxWidth * 0.9, - enableSearch: false, - enableFilter: false, - initialSelection: relativeTime, - onSelected: (value) => onTimeChange(value!), - dropdownMenuEntries: [ - DropdownMenuEntry(value: 0, label: "all".tr()), - DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()), - DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})), - DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})), - DropdownMenuEntry( - value: now - .difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second)) - .inDays, - label: "map_settings_date_range_option_year".tr(), - ), - DropdownMenuEntry( - value: now - .difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second)) - .inDays, - label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}), - ), - ], + return Padding( + padding: const EdgeInsets.only(left: 16, right: 28.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "date_range".t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), - ), - ], + Flexible( + child: DropdownMenu( + enableSearch: false, + enableFilter: false, + initialSelection: relativeTime, + onSelected: (value) => onTimeChange(value!), + dropdownMenuEntries: [ + DropdownMenuEntry(value: 0, label: "all".t(context: context)), + DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)), + DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})), + DropdownMenuEntry( + value: 30, + label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}), + ), + DropdownMenuEntry( + value: now + .difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second)) + .inDays, + label: "map_settings_date_range_option_year".t(context: context), + ), + DropdownMenuEntry( + value: now + .difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second)) + .inDays, + label: "map_settings_date_range_option_years".t(args: {'years': "3"}), + ), + ], + ), + ), + ], + ), ); } } diff --git a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart index 63f35ebe4c..7866c0ecdc 100644 --- a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart +++ b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget { padding: const EdgeInsets.only(bottom: 20), child: Center( child: Text( - "map_settings_theme_settings", - style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), - ).tr(), + "map_settings_theme_settings".t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), + ), ), ), Row(