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(