mirror of
https://github.com/immich-app/immich.git
synced 2026-06-21 22:32:10 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a213f3d81 | |||
| 5ead92bb12 | |||
| ee2c3e14c3 | |||
| f812c5846a | |||
| 3f93169301 | |||
| 8937fe0133 | |||
| 0a055d0fc7 | |||
| 334ebbfe7d | |||
| 57dd127162 |
@@ -2,7 +2,6 @@
|
||||
"name": "Immich - Backend, Frontend and ML",
|
||||
"service": "immich-server",
|
||||
"runServices": [
|
||||
"immich-init",
|
||||
"immich-server",
|
||||
"redis",
|
||||
"database",
|
||||
@@ -32,8 +31,29 @@
|
||||
"tasks": {
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Fix Permissions, Install Dependencies",
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "dedicated",
|
||||
"showReuseMessage": true,
|
||||
"clear": false,
|
||||
"group": "Devcontainer tasks",
|
||||
"close": true
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "default"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Immich API Server (Nest)",
|
||||
"dependsOn": ["Fix Permissions, Install Dependencies"],
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
|
||||
"isBackground": true,
|
||||
@@ -54,6 +74,7 @@
|
||||
},
|
||||
{
|
||||
"label": "Immich Web Server (Vite)",
|
||||
"dependsOn": ["Fix Permissions, Install Dependencies"],
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
|
||||
"isBackground": true,
|
||||
@@ -109,8 +130,8 @@
|
||||
}
|
||||
},
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/usr/src/app",
|
||||
"remoteUser": "root",
|
||||
"workspaceFolder": "/workspaces/immich",
|
||||
"remoteUser": "node",
|
||||
"userEnvProbe": "loginInteractiveShell",
|
||||
"remoteEnv": {
|
||||
// The location where your uploaded files are stored
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
services:
|
||||
immich-app-base:
|
||||
image: busybox
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
target: dev-container-mobile
|
||||
environment:
|
||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||
volumes:
|
||||
volumes: !override # bind mount host to /workspaces/immich
|
||||
- ..:/workspaces/immich
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "Immich - Mobile",
|
||||
"service": "immich-server",
|
||||
"runServices": [
|
||||
"immich-init",
|
||||
"immich-server",
|
||||
"redis",
|
||||
"database",
|
||||
@@ -36,7 +35,7 @@
|
||||
},
|
||||
"forwardPorts": [],
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/usr/src/app",
|
||||
"workspaceFolder": "/workspaces/immich",
|
||||
"remoteUser": "node",
|
||||
"userEnvProbe": "loginInteractiveShell",
|
||||
"remoteEnv": {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
|
||||
export DEV_PORT="${DEV_PORT:-3000}"
|
||||
|
||||
# search for immich directory inside workspace.
|
||||
# /workspaces/immich is the bind mount, but other directories can be mounted if runing
|
||||
# Devcontainer: Clone [repository|pull request] in container volumne
|
||||
WORKSPACES_DIR="/workspaces"
|
||||
IMMICH_DIR="$WORKSPACES_DIR/immich"
|
||||
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
|
||||
|
||||
log() {
|
||||
@@ -25,8 +30,52 @@ run_cmd() {
|
||||
return "${PIPESTATUS[0]}"
|
||||
}
|
||||
|
||||
export IMMICH_WORKSPACE="/usr/src/app"
|
||||
# Find directories excluding /workspaces/immich
|
||||
mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
|
||||
|
||||
if [ ${#other_dirs[@]} -gt 1 ]; then
|
||||
log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
|
||||
exit 1
|
||||
elif [ ${#other_dirs[@]} -eq 1 ]; then
|
||||
export IMMICH_WORKSPACE="${other_dirs[0]}"
|
||||
else
|
||||
export IMMICH_WORKSPACE="$IMMICH_DIR"
|
||||
fi
|
||||
|
||||
log "Found immich workspace in $IMMICH_WORKSPACE"
|
||||
log ""
|
||||
|
||||
fix_permissions() {
|
||||
|
||||
log "Fixing permissions for ${IMMICH_WORKSPACE}"
|
||||
|
||||
# Change ownership for directories that exist
|
||||
for dir in "${IMMICH_WORKSPACE}/.vscode" \
|
||||
"${IMMICH_WORKSPACE}/server/upload" \
|
||||
"${IMMICH_WORKSPACE}/.pnpm-store" \
|
||||
"${IMMICH_WORKSPACE}/.github/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/cli/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/e2e/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/server/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/server/dist" \
|
||||
"${IMMICH_WORKSPACE}/web/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/web/dist"; do
|
||||
if [ -d "$dir" ]; then
|
||||
run_cmd sudo chown node -R "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
log ""
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
|
||||
log "Installing dependencies"
|
||||
(
|
||||
cd "${IMMICH_WORKSPACE}" || exit 1
|
||||
export CI=1 FROZEN=1 OFFLINE=1
|
||||
run_cmd make setup-web-dev setup-server-dev
|
||||
)
|
||||
log ""
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
services:
|
||||
immich-app-base:
|
||||
image: busybox
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
target: dev-container-server
|
||||
env_file: !reset []
|
||||
hostname: immich-dev
|
||||
environment:
|
||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||
volumes:
|
||||
volumes: !override
|
||||
- ..:/workspaces/immich
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# shellcheck source=common.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /immich-devcontainer/container-common.sh
|
||||
|
||||
log "Setting up Immich dev container..."
|
||||
fix_permissions
|
||||
|
||||
log "Setup complete, please wait while backend and frontend services automatically start"
|
||||
log
|
||||
log "If necessary, the services may be manually started using"
|
||||
log
|
||||
log "$ /immich-devcontainer/container-start-backend.sh"
|
||||
log "$ /immich-devcontainer/container-start-frontend.sh"
|
||||
log
|
||||
log "From different terminal windows, as these scripts automatically restart the server"
|
||||
log "on error, and will continuously run in a loop"
|
||||
+5
-11
@@ -4,18 +4,12 @@ module.exports = {
|
||||
if (!pkg.name) {
|
||||
return pkg;
|
||||
}
|
||||
// make exiftool-vendored.pl a regular dependency since Docker prod
|
||||
// images build with --no-optional to reduce image size
|
||||
if (pkg.name === "exiftool-vendored") {
|
||||
const binaryPackage =
|
||||
process.platform === "win32"
|
||||
? "exiftool-vendored.exe"
|
||||
: "exiftool-vendored.pl";
|
||||
|
||||
if (pkg.optionalDependencies[binaryPackage]) {
|
||||
pkg.dependencies[binaryPackage] =
|
||||
pkg.optionalDependencies[binaryPackage];
|
||||
delete pkg.optionalDependencies[binaryPackage];
|
||||
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||
// make exiftool-vendored.pl a regular dependency
|
||||
pkg.dependencies["exiftool-vendored.pl"] =
|
||||
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
}
|
||||
}
|
||||
return pkg;
|
||||
|
||||
@@ -7,15 +7,7 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
|
||||
import createFetchMock from 'vitest-fetch-mock';
|
||||
|
||||
import {
|
||||
checkForDuplicates,
|
||||
deleteFiles,
|
||||
findSidecar,
|
||||
getAlbumName,
|
||||
startWatch,
|
||||
uploadFiles,
|
||||
UploadOptionsDto,
|
||||
} from 'src/commands/asset';
|
||||
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
|
||||
|
||||
vi.mock('@immich/sdk');
|
||||
|
||||
@@ -317,85 +309,3 @@ describe('startWatch', () => {
|
||||
await fs.promises.rm(testFolder, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSidecar', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-'));
|
||||
testFilePath = path.join(testDir, 'test.jpg');
|
||||
fs.writeFileSync(testFilePath, 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should find sidecar file with photo.xmp naming convention', () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBe(sidecarPath);
|
||||
});
|
||||
|
||||
it('should find sidecar file with photo.ext.xmp naming convention', () => {
|
||||
const sidecarPath = path.join(testDir, 'test.jpg.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBe(sidecarPath);
|
||||
});
|
||||
|
||||
it('should prefer photo.ext.xmp over photo.xmp when both exist', () => {
|
||||
const sidecarPath1 = path.join(testDir, 'test.xmp');
|
||||
const sidecarPath2 = path.join(testDir, 'test.jpg.xmp');
|
||||
fs.writeFileSync(sidecarPath1, 'xmp data 1');
|
||||
fs.writeFileSync(sidecarPath2, 'xmp data 2');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
// Should return the first one found (photo.xmp) based on the order in the code
|
||||
expect(result).toBe(sidecarPath1);
|
||||
});
|
||||
|
||||
it('should return undefined when no sidecar file exists', () => {
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-'));
|
||||
testFilePath = path.join(testDir, 'test.jpg');
|
||||
fs.writeFileSync(testFilePath, 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should delete asset and sidecar file when main file is deleted', async () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 });
|
||||
|
||||
expect(fs.existsSync(testFilePath)).toBe(false);
|
||||
expect(fs.existsSync(sidecarPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not delete sidecar file when delete option is false', async () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 });
|
||||
|
||||
expect(fs.existsSync(testFilePath)).toBe(true);
|
||||
expect(fs.existsSync(sidecarPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-32
@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
|
||||
import { MultiBar, Presets, SingleBar } from 'cli-progress';
|
||||
import { chunk } from 'lodash-es';
|
||||
import micromatch from 'micromatch';
|
||||
import { Stats, createReadStream, existsSync } from 'node:fs';
|
||||
import { Stats, createReadStream } from 'node:fs';
|
||||
import { stat, unlink } from 'node:fs/promises';
|
||||
import path, { basename } from 'node:path';
|
||||
import { Queue } from 'src/queue';
|
||||
@@ -403,6 +403,23 @@ export const uploadFiles = async (
|
||||
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
|
||||
const { baseUrl, headers } = defaults;
|
||||
|
||||
const assetPath = path.parse(input);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
const sidecarsFiles = await Promise.all(
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
return new UploadFile(sidecarPath, stats.size);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
|
||||
formData.append('deviceId', 'CLI');
|
||||
@@ -412,15 +429,8 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
formData.append('isFavorite', 'false');
|
||||
formData.append('assetData', new UploadFile(input, stats.size));
|
||||
|
||||
const sidecarPath = findSidecar(input);
|
||||
if (sidecarPath) {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
const sidecarData = new UploadFile(sidecarPath, stats.size);
|
||||
formData.append('sidecarData', sidecarData);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/assets`, {
|
||||
@@ -436,19 +446,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const findSidecar = (filepath: string): string | undefined => {
|
||||
const assetPath = path.parse(filepath);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) {
|
||||
if (existsSync(sidecarPath)) {
|
||||
return sidecarPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
let fileCount = 0;
|
||||
if (options.delete) {
|
||||
fileCount += uploaded.length;
|
||||
@@ -476,15 +474,7 @@ export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], option
|
||||
|
||||
const chunkDelete = async (files: Asset[]) => {
|
||||
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||
await Promise.all(
|
||||
assetBatch.map(async (input: Asset) => {
|
||||
await unlink(input.filepath);
|
||||
const sidecarPath = findSidecar(input.filepath);
|
||||
if (sidecarPath) {
|
||||
await unlink(sidecarPath);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,8 +253,7 @@ describe('/asset', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.id).toEqual(facesAsset.id);
|
||||
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
expect(sortedPeople).toMatchObject(expectedFaces);
|
||||
expect(body.people).toMatchObject(expectedFaces);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
@@ -103,8 +103,11 @@ export const thumbnailUtils = {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
await expect(
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
|
||||
@@ -15,7 +15,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
flutter = "3.41.2"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.29.3"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
|
||||
+16
-22
@@ -38,10 +38,10 @@ PODS:
|
||||
- local_auth_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- MapLibre (6.23.0)
|
||||
- maplibre_ios (0.0.1):
|
||||
- MapLibre (6.14.0)
|
||||
- maplibre_gl (0.0.1):
|
||||
- Flutter
|
||||
- MapLibre (~> 6.21)
|
||||
- MapLibre (= 6.14.0)
|
||||
- native_video_player (1.0.0):
|
||||
- Flutter
|
||||
- network_info_plus (0.0.1):
|
||||
@@ -58,8 +58,6 @@ PODS:
|
||||
- photo_manager (3.7.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- pointer_interceptor_ios (0.0.1):
|
||||
- Flutter
|
||||
- SAMKeychain (1.5.3)
|
||||
- share_handler_ios (0.0.14):
|
||||
- Flutter
|
||||
@@ -77,16 +75,16 @@ PODS:
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.49.2):
|
||||
- sqlite3/common (= 3.49.2)
|
||||
- sqlite3/common (3.49.2)
|
||||
- sqlite3/dbstatvtab (3.49.2):
|
||||
- sqlite3 (3.49.1):
|
||||
- sqlite3/common (= 3.49.1)
|
||||
- sqlite3/common (3.49.1)
|
||||
- sqlite3/dbstatvtab (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.49.2):
|
||||
- sqlite3/fts5 (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.49.2):
|
||||
- sqlite3/perf-threadsafe (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.49.2):
|
||||
- sqlite3/rtree (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
@@ -120,7 +118,7 @@ DEPENDENCIES:
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- maplibre_ios (from `.symlinks/plugins/maplibre_ios/ios`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
@@ -128,7 +126,6 @@ DEPENDENCIES:
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
||||
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
|
||||
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -181,8 +178,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
maplibre_ios:
|
||||
:path: ".symlinks/plugins/maplibre_ios/ios"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
native_video_player:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
network_info_plus:
|
||||
@@ -197,8 +194,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
photo_manager:
|
||||
:path: ".symlinks/plugins/photo_manager/ios"
|
||||
pointer_interceptor_ios:
|
||||
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
||||
share_handler_ios:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios"
|
||||
share_handler_ios_models:
|
||||
@@ -235,8 +230,8 @@ SPEC CHECKSUMS:
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
|
||||
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
||||
MapLibre: c0fcafabb341f230657d959970c6eb47fb55750e
|
||||
maplibre_ios: 05031d5f79702672d2c01cc77b6ba3187d4bf896
|
||||
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
@@ -244,14 +239,13 @@ SPEC CHECKSUMS:
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
|
||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
@@ -446,7 +446,6 @@
|
||||
packageReferences = (
|
||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
@@ -1251,14 +1250,6 @@
|
||||
minimumVersion = 1.5.0;
|
||||
};
|
||||
};
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/maplibre/maplibre-gl-native-distribution";
|
||||
requirement = {
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 6.21.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
|
||||
@@ -10,15 +10,6 @@
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "maplibre-gl-native-distribution",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution",
|
||||
"state" : {
|
||||
"revision" : "2aefb4dd47ca6e897c93086f348a457839aac2fe",
|
||||
"version" : "6.23.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grdb.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class Marker {
|
||||
final Geographic location;
|
||||
final LatLng location;
|
||||
final String assetId;
|
||||
|
||||
const Marker({required this.location, required this.assetId});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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/maplibre.dart' hide Marker;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
typedef MapMarkerSource = Future<List<Marker>> Function(LngLatBounds? bounds);
|
||||
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
|
||||
|
||||
typedef MapQuery = ({MapMarkerSource markerSource});
|
||||
|
||||
@@ -21,5 +21,5 @@ class MapService {
|
||||
|
||||
MapService(MapQuery query) : _markerSource = query.markerSource;
|
||||
|
||||
Future<List<Marker>> Function(LngLatBounds? bounds) get getMarkers => _markerSource;
|
||||
Future<List<Marker>> Function(LatLngBounds? bounds) get getMarkers => _markerSource;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
extension WithinBounds on LngLatBounds {
|
||||
extension WithinBounds on LatLngBounds {
|
||||
/// Checks whether [point] is inside bounds
|
||||
bool contains(Geographic point) {
|
||||
return containsBounds(
|
||||
LngLatBounds(
|
||||
longitudeWest: point.lon,
|
||||
longitudeEast: point.lon,
|
||||
latitudeSouth: point.lat,
|
||||
latitudeNorth: point.lat,
|
||||
),
|
||||
);
|
||||
bool contains(LatLng point) {
|
||||
final sw = point;
|
||||
final ne = point;
|
||||
return containsBounds(LatLngBounds(southwest: sw, northeast: ne));
|
||||
}
|
||||
|
||||
/// Checks whether [bounds] is contained inside bounds
|
||||
bool containsBounds(LngLatBounds bounds) {
|
||||
return (bounds.latitudeSouth >= latitudeSouth) &&
|
||||
(bounds.latitudeNorth <= latitudeNorth) &&
|
||||
(bounds.longitudeWest >= longitudeWest) &&
|
||||
(bounds.longitudeEast <= longitudeEast);
|
||||
bool containsBounds(LatLngBounds bounds) {
|
||||
final sw = bounds.southwest;
|
||||
final ne = bounds.northeast;
|
||||
return (sw.latitude >= southwest.latitude) &&
|
||||
(ne.latitude <= northeast.latitude) &&
|
||||
(sw.longitude >= southwest.longitude) &&
|
||||
(ne.longitude <= northeast.longitude);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
extension MapMarkers on MapController {
|
||||
extension MapMarkers on MapLibreMapController {
|
||||
static var _completer = Completer()..complete();
|
||||
|
||||
Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async {
|
||||
return style!.addSource(
|
||||
GeoJsonSource(
|
||||
id: MapUtils.defaultSourceId,
|
||||
data: jsonEncode(MapUtils.generateGeoJsonForMarkers(markers.toList())),
|
||||
),
|
||||
return addSource(
|
||||
MapUtils.defaultSourceId,
|
||||
GeojsonSourceProperties(data: MapUtils.generateGeoJsonForMarkers(markers.toList())),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,28 +27,63 @@ extension MapMarkers on MapController {
|
||||
// !! Make sure to remove layers before sources else the native
|
||||
// maplibre library would crash when removing the source saying that
|
||||
// the source is still in use
|
||||
try {
|
||||
await style!.removeLayer(MapUtils.defaultHeatMapLayerId);
|
||||
} catch (_) {
|
||||
// Layer may not exist
|
||||
final existingLayers = await getLayerIds();
|
||||
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
|
||||
await removeLayer(MapUtils.defaultHeatMapLayerId);
|
||||
}
|
||||
|
||||
try {
|
||||
await style!.removeSource(MapUtils.defaultSourceId);
|
||||
} catch (_) {
|
||||
// Source may not exist
|
||||
final existingSources = await getSourceIds();
|
||||
if (existingSources.contains(MapUtils.defaultSourceId)) {
|
||||
await removeSource(MapUtils.defaultSourceId);
|
||||
}
|
||||
|
||||
await addGeoJSONSourceForMarkers(markers);
|
||||
|
||||
await style!.addLayer(
|
||||
const HeatmapStyleLayer(
|
||||
id: MapUtils.defaultHeatMapLayerId,
|
||||
sourceId: MapUtils.defaultSourceId,
|
||||
paint: MapUtils.defaultHeatMapLayerPaint,
|
||||
),
|
||||
);
|
||||
if (Platform.isAndroid) {
|
||||
await addCircleLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
const CircleLayerProperties(
|
||||
circleRadius: 10,
|
||||
circleColor: "rgba(150,86,34,0.7)",
|
||||
circleBlur: 1.0,
|
||||
circleOpacity: 0.7,
|
||||
circleStrokeWidth: 0.1,
|
||||
circleStrokeColor: "rgba(203,46,19,0.5)",
|
||||
circleStrokeOpacity: 0.7,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await addHeatmapLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
MapUtils.defaultHeatMapLayerProperties,
|
||||
);
|
||||
}
|
||||
|
||||
_completer.complete();
|
||||
}
|
||||
|
||||
Future<Symbol?> addMarkerAtLatLng(LatLng centre) async {
|
||||
// no marker is displayed if asset-path is incorrect
|
||||
try {
|
||||
final ByteData bytes = await rootBundle.load("assets/location-pin.png");
|
||||
await addImage("mapMarker", bytes.buffer.asUint8List());
|
||||
return addSymbol(SymbolOptions(geometry: centre, iconImage: "mapMarker", iconSize: 0.15, iconAnchor: "bottom"));
|
||||
} finally {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<LatLngBounds> getBoundsFromPoint(Point<double> point, double distance) async {
|
||||
final southWestPx = Point(point.x - distance, point.y + distance);
|
||||
final northEastPx = Point(point.x + distance, point.y - distance);
|
||||
|
||||
final southWest = await toLatLng(southWestPx);
|
||||
final northEast = await toLatLng(northEastPx);
|
||||
|
||||
return LatLngBounds(southwest: southWest, northeast: northEast);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// ignore_for_file: experimental_member_use
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
@@ -6,7 +6,7 @@ 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/maplibre.dart' hide Marker;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class DriftMapRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
@@ -42,7 +42,7 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
|
||||
Future<List<Marker>> _watchMapMarker({
|
||||
Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter,
|
||||
LngLatBounds? bounds,
|
||||
LatLngBounds? bounds,
|
||||
}) async {
|
||||
final assetId = _db.remoteExifEntity.assetId;
|
||||
final latitude = _db.remoteExifEntity.latitude;
|
||||
@@ -66,21 +66,20 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
final rows = await query.get();
|
||||
return List.generate(rows.length, (i) {
|
||||
final row = rows[i];
|
||||
return Marker(
|
||||
assetId: row.read(assetId)!,
|
||||
location: Geographic(lat: row.read(latitude)!, lon: row.read(longitude)!),
|
||||
);
|
||||
return Marker(assetId: row.read(assetId)!, location: LatLng(row.read(latitude)!, row.read(longitude)!));
|
||||
}, growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
extension MapBounds on $RemoteExifEntityTable {
|
||||
Expression<bool> inBounds(LngLatBounds bounds) {
|
||||
final latInBounds = latitude.isBetweenValues(bounds.latitudeSouth, bounds.latitudeNorth);
|
||||
final longInBounds = bounds.longitudeWest <= bounds.longitudeEast
|
||||
? longitude.isBetweenValues(bounds.longitudeWest, bounds.longitudeEast)
|
||||
: (longitude.isBiggerOrEqualValue(bounds.longitudeWest) |
|
||||
longitude.isSmallerOrEqualValue(bounds.longitudeEast));
|
||||
Expression<bool> inBounds(LatLngBounds bounds) {
|
||||
final southwest = bounds.southwest;
|
||||
final northeast = bounds.northeast;
|
||||
|
||||
final latInBounds = latitude.isBetweenValues(southwest.latitude, northeast.latitude);
|
||||
final longInBounds = southwest.longitude <= northeast.longitude
|
||||
? longitude.isBetweenValues(southwest.longitude, northeast.longitude)
|
||||
: (longitude.isBiggerOrEqualValue(southwest.longitude) | longitude.isSmallerOrEqualValue(northeast.longitude));
|
||||
return latInBounds & longInBounds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
@@ -170,12 +170,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateLocation(List<String> ids, Geographic location) {
|
||||
Future<void> updateLocation(List<String> ids, LatLng location) {
|
||||
return _db.batch((batch) async {
|
||||
for (final id in ids) {
|
||||
batch.update(
|
||||
_db.remoteExifEntity,
|
||||
RemoteExifEntityCompanion(latitude: Value(location.lat), longitude: Value(location.lon)),
|
||||
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
|
||||
where: (e) => e.assetId.equals(id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:stream_transform/stream_transform.dart';
|
||||
|
||||
class TimelineMapOptions {
|
||||
final LngLatBounds bounds;
|
||||
final LatLngBounds bounds;
|
||||
final bool onlyFavorites;
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class MapMarker {
|
||||
final Geographic latLng;
|
||||
final LatLng latLng;
|
||||
final String assetRemoteId;
|
||||
const MapMarker({required this.latLng, required this.assetRemoteId});
|
||||
|
||||
MapMarker copyWith({Geographic? latLng, String? assetRemoteId}) {
|
||||
MapMarker copyWith({LatLng? latLng, String? assetRemoteId}) {
|
||||
return MapMarker(latLng: latLng ?? this.latLng, assetRemoteId: assetRemoteId ?? this.assetRemoteId);
|
||||
}
|
||||
|
||||
MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = Geographic(lat: dto.lat, lon: dto.lon), assetRemoteId = dto.id;
|
||||
MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = LatLng(dto.lat, dto.lon), assetRemoteId = dto.id;
|
||||
|
||||
@override
|
||||
String toString() => 'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)';
|
||||
|
||||
@@ -17,7 +17,7 @@ import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_avatar.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LibraryPage extends ConsumerWidget {
|
||||
@@ -325,7 +325,7 @@ class PlacesCollectionCard extends StatelessWidget {
|
||||
child: IgnorePointer(
|
||||
child: MapThumbnail(
|
||||
zoom: 8,
|
||||
centre: const Geographic(lat: 21.44950, lon: -157.91959),
|
||||
centre: const LatLng(21.44950, -157.91959),
|
||||
showAttribution: false,
|
||||
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
|
||||
),
|
||||
|
||||
@@ -15,12 +15,12 @@ import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PlacesCollectionPage extends HookConsumerWidget {
|
||||
const PlacesCollectionPage({super.key, this.currentLocation});
|
||||
final Geographic? currentLocation;
|
||||
final LatLng? currentLocation;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final places = ref.watch(getAllPlacesProvider);
|
||||
@@ -61,7 +61,7 @@ class PlacesCollectionPage extends HookConsumerWidget {
|
||||
child: MapThumbnail(
|
||||
onTap: (_, __) => context.pushRoute(MapRoute(initialLocation: currentLocation)),
|
||||
zoom: 8,
|
||||
centre: currentLocation ?? const Geographic(lat: 21.44950, lon: -157.91959),
|
||||
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
|
||||
showAttribution: false,
|
||||
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -11,9 +12,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/models/map/map_event.model.dart' as app;
|
||||
import 'package:immich_mobile/models/map/map_event.model.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
@@ -26,25 +26,25 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MapPage extends HookConsumerWidget {
|
||||
const MapPage({super.key, this.initialLocation});
|
||||
final Geographic? initialLocation;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapController = useRef<MapController?>(null);
|
||||
final mapController = useRef<MapLibreMapController?>(null);
|
||||
final markers = useRef<List<MapMarker>>([]);
|
||||
final markersInBounds = useRef<List<MapMarker>>([]);
|
||||
final bottomSheetStreamController = useStreamController<app.MapEvent>();
|
||||
final selectedMarker = useValueNotifier<MapMarker?>(null);
|
||||
final bottomSheetStreamController = useStreamController<MapEvent>();
|
||||
final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null);
|
||||
final assetsDebouncer = useDebouncer();
|
||||
final layerDebouncer = useDebouncer(interval: const Duration(seconds: 1));
|
||||
final isLoading = useProcessingOverlay();
|
||||
@@ -55,17 +55,19 @@ class MapPage extends HookConsumerWidget {
|
||||
|
||||
// updates the markersInBounds value with the map markers that are visible in the current
|
||||
// map camera bounds
|
||||
void updateAssetsInBounds() {
|
||||
if (mapController.value == null) return;
|
||||
Future<void> updateAssetsInBounds() async {
|
||||
// Guard map not created
|
||||
if (mapController.value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = mapController.value!.getVisibleRegion();
|
||||
final bounds = await mapController.value!.getVisibleRegion();
|
||||
final inBounds = markers.value
|
||||
.where((m) => bounds.contains(Geographic(lat: m.latLng.lat, lon: m.latLng.lon)))
|
||||
.where((m) => bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)))
|
||||
.toList();
|
||||
|
||||
// Notify bottom sheet to update asset grid only when there are new assets
|
||||
if (markersInBounds.value.length != inBounds.length) {
|
||||
bottomSheetStreamController.add(app.MapAssetsInBoundsUpdated(inBounds.map((e) => e.assetRemoteId).toList()));
|
||||
bottomSheetStreamController.add(MapAssetsInBoundsUpdated(inBounds.map((e) => e.assetRemoteId).toList()));
|
||||
}
|
||||
markersInBounds.value = inBounds;
|
||||
}
|
||||
@@ -97,67 +99,57 @@ class MapPage extends HookConsumerWidget {
|
||||
|
||||
// Refetch markers when map state is changed
|
||||
ref.listen(mapStateNotifierProvider, (_, current) {
|
||||
if (!current.shouldRefetchMarkers) return;
|
||||
|
||||
markerDebouncer.run(() {
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
// Reset marker
|
||||
selectedMarker.value = null;
|
||||
loadMarkers();
|
||||
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
|
||||
});
|
||||
if (current.shouldRefetchMarkers) {
|
||||
markerDebouncer.run(() {
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
// Reset marker
|
||||
selectedMarker.value = null;
|
||||
loadMarkers();
|
||||
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void selectMarker(MapMarker marker) {
|
||||
selectedMarker.value = marker;
|
||||
// updates the selected markers position based on the current map camera
|
||||
Future<void> updateAssetMarkerPosition(MapMarker marker, {bool shouldAnimate = true}) async {
|
||||
final assetPoint = await mapController.value!.toScreenLocation(marker.latLng);
|
||||
selectedMarker.value = _AssetMarkerMeta(point: assetPoint, marker: marker, shouldAnimate: shouldAnimate);
|
||||
(assetPoint, marker, shouldAnimate);
|
||||
}
|
||||
|
||||
// finds the nearest asset marker from the tap point and store it as the selectedMarker
|
||||
void onMarkerClicked(Offset point) {
|
||||
if (mapController.value == null) return;
|
||||
|
||||
final features = mapController.value!.featuresInRect(
|
||||
Rect.fromCircle(center: point, radius: 50),
|
||||
layerIds: [MapUtils.defaultHeatMapLayerId],
|
||||
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
|
||||
// Guard map not created
|
||||
if (mapController.value == null) {
|
||||
return;
|
||||
}
|
||||
final latlngBound = await mapController.value!.getBoundsFromPoint(point, 50);
|
||||
final marker = markersInBounds.value.firstWhereOrNull(
|
||||
(m) => latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
|
||||
);
|
||||
|
||||
final featureId = features.firstOrNull?.id?.toString();
|
||||
|
||||
final marker = featureId != null
|
||||
? markersInBounds.value.firstWhereOrNull((m) => m.assetRemoteId == featureId)
|
||||
: null;
|
||||
|
||||
if (marker != null) {
|
||||
selectMarker(marker);
|
||||
return;
|
||||
await updateAssetMarkerPosition(marker);
|
||||
} else {
|
||||
// If no asset was previously selected and no new asset is available, close the bottom sheet
|
||||
if (selectedMarker.value == null) {
|
||||
bottomSheetStreamController.add(const MapCloseBottomSheet());
|
||||
}
|
||||
selectedMarker.value = null;
|
||||
}
|
||||
|
||||
if (selectedMarker.value == null) {
|
||||
// If no asset was previously selected and no new asset is available,
|
||||
// close the bottom sheet.
|
||||
bottomSheetStreamController.add(const app.MapCloseBottomSheet());
|
||||
return;
|
||||
}
|
||||
|
||||
selectedMarker.value = null;
|
||||
}
|
||||
|
||||
void onMapCreated(MapController controller) {
|
||||
void onMapCreated(MapLibreMapController controller) async {
|
||||
mapController.value = controller;
|
||||
}
|
||||
|
||||
void onMapEvent(MapEvent event) {
|
||||
switch (event) {
|
||||
case MapEventClick():
|
||||
onMarkerClicked(event.screenPoint);
|
||||
case MapEventCameraIdle():
|
||||
assetsDebouncer.run(updateAssetsInBounds);
|
||||
default:
|
||||
}
|
||||
controller.addListener(() {
|
||||
if (controller.isCameraMoving && selectedMarker.value != null) {
|
||||
updateAssetMarkerPosition(selectedMarker.value!.marker, shouldAnimate: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onMarkerTapped() async {
|
||||
final assetId = selectedMarker.value?.assetRemoteId;
|
||||
final assetId = selectedMarker.value?.marker.assetRemoteId;
|
||||
if (assetId == null) {
|
||||
return;
|
||||
}
|
||||
@@ -179,10 +171,14 @@ class MapPage extends HookConsumerWidget {
|
||||
|
||||
/// BOTTOM SHEET CALLBACKS
|
||||
|
||||
Future<void> onMapMoved() async {
|
||||
assetsDebouncer.run(updateAssetsInBounds);
|
||||
}
|
||||
|
||||
void onBottomSheetScrolled(String assetRemoteId) {
|
||||
final assetMarker = markersInBounds.value.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
|
||||
if (assetMarker != null) {
|
||||
selectMarker(assetMarker);
|
||||
updateAssetMarkerPosition(assetMarker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,11 +187,10 @@ class MapPage extends HookConsumerWidget {
|
||||
if (mapController.value != null && assetMarker != null) {
|
||||
// Offset the latitude a little to show the marker just above the viewports center
|
||||
final offset = context.isMobile ? 0.02 : 0;
|
||||
final latlng = Geographic(lat: assetMarker.latLng.lat - offset, lon: assetMarker.latLng.lon);
|
||||
final latlng = LatLng(assetMarker.latLng.latitude - offset, assetMarker.latLng.longitude);
|
||||
mapController.value!.animateCamera(
|
||||
center: latlng,
|
||||
zoom: mapZoomToAssetLevel,
|
||||
nativeDuration: Durations.extralong2,
|
||||
CameraUpdate.newLatLngZoom(latlng, mapZoomToAssetLevel),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -216,9 +211,8 @@ class MapPage extends HookConsumerWidget {
|
||||
|
||||
if (mapController.value != null && location != null) {
|
||||
await mapController.value!.animateCamera(
|
||||
center: Geographic(lat: location.latitude, lon: location.longitude),
|
||||
zoom: mapZoomToAssetLevel,
|
||||
nativeDuration: Durations.extralong2,
|
||||
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), mapZoomToAssetLevel),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -240,8 +234,9 @@ class MapPage extends HookConsumerWidget {
|
||||
style: style,
|
||||
selectedMarker: selectedMarker,
|
||||
onMapCreated: onMapCreated,
|
||||
onMapEvent: onMapEvent,
|
||||
onStyleLoaded: (_) => reloadLayers(),
|
||||
onMapMoved: onMapMoved,
|
||||
onMapClicked: onMarkerClicked,
|
||||
onStyleLoaded: reloadLayers,
|
||||
onMarkerTapped: onMarkerTapped,
|
||||
),
|
||||
// Should be a part of the body and not scaffold::bottomsheet for the
|
||||
@@ -271,8 +266,9 @@ class MapPage extends HookConsumerWidget {
|
||||
style: style,
|
||||
selectedMarker: selectedMarker,
|
||||
onMapCreated: onMapCreated,
|
||||
onMapEvent: onMapEvent,
|
||||
onStyleLoaded: (_) => reloadLayers(),
|
||||
onMapMoved: onMapMoved,
|
||||
onMapClicked: onMarkerClicked,
|
||||
onStyleLoaded: reloadLayers,
|
||||
onMarkerTapped: onMarkerTapped,
|
||||
),
|
||||
Positioned(
|
||||
@@ -306,19 +302,32 @@ class MapPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetMarkerMeta {
|
||||
final Point<num> point;
|
||||
final MapMarker marker;
|
||||
final bool shouldAnimate;
|
||||
|
||||
const _AssetMarkerMeta({required this.point, required this.marker, required this.shouldAnimate});
|
||||
|
||||
@override
|
||||
String toString() => '_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
|
||||
}
|
||||
|
||||
class _MapWithMarker extends StatelessWidget {
|
||||
final AsyncValue<String> style;
|
||||
final void Function(MapController) onMapCreated;
|
||||
final void Function(MapEvent) onMapEvent;
|
||||
final void Function(StyleController) onStyleLoaded;
|
||||
final MapCreatedCallback onMapCreated;
|
||||
final OnCameraIdleCallback onMapMoved;
|
||||
final OnMapClickCallback onMapClicked;
|
||||
final OnStyleLoadedCallback onStyleLoaded;
|
||||
final Function()? onMarkerTapped;
|
||||
final ValueNotifier<MapMarker?> selectedMarker;
|
||||
final Geographic? initialLocation;
|
||||
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
const _MapWithMarker({
|
||||
required this.style,
|
||||
required this.onMapCreated,
|
||||
required this.onMapEvent,
|
||||
required this.onMapMoved,
|
||||
required this.onMapClicked,
|
||||
required this.onStyleLoaded,
|
||||
required this.selectedMarker,
|
||||
this.onMarkerTapped,
|
||||
@@ -327,44 +336,48 @@ class _MapWithMarker extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return style.widgetWhen(
|
||||
onData: (style) => MapLibreMap(
|
||||
options: MapOptions(
|
||||
initCenter: initialLocation ?? const Geographic(lat: 0, lon: 0),
|
||||
initZoom: initialLocation != null ? 12 : 0,
|
||||
initStyle: style,
|
||||
gestures: const MapGestures.all(pitch: false, rotate: false),
|
||||
return LayoutBuilder(
|
||||
builder: (ctx, constraints) => SizedBox(
|
||||
height: constraints.maxHeight,
|
||||
width: constraints.maxWidth,
|
||||
child: Stack(
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => MapLibreMap(
|
||||
attributionButtonMargins: const Point(8, kToolbarHeight),
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: initialLocation ?? const LatLng(0, 0),
|
||||
zoom: initialLocation != null ? 12 : 0,
|
||||
),
|
||||
styleString: style,
|
||||
// This is needed to update the selectedMarker's position on map camera updates
|
||||
// The changes are notified through the mapController ValueListener which is added in [onMapCreated]
|
||||
trackCameraPosition: true,
|
||||
onMapCreated: onMapCreated,
|
||||
onCameraIdle: onMapMoved,
|
||||
onMapClick: onMapClicked,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
tiltGesturesEnabled: false,
|
||||
dragEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonPosition: AttributionButtonPosition.topRight,
|
||||
rotateGesturesEnabled: false,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedMarker,
|
||||
builder: (ctx, value, _) => value != null
|
||||
? PositionedAssetMarkerIcon(
|
||||
point: value.point,
|
||||
assetRemoteId: value.marker.assetRemoteId,
|
||||
assetThumbhash: '',
|
||||
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
|
||||
onTap: onMarkerTapped,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoaded: onStyleLoaded,
|
||||
onEvent: onMapEvent,
|
||||
children: [
|
||||
ValueListenableBuilder<MapMarker?>(
|
||||
valueListenable: selectedMarker,
|
||||
builder: (ctx, marker, _) => marker != null
|
||||
? WidgetLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: marker.latLng,
|
||||
size: const Size(100, 100),
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: GestureDetector(
|
||||
onTap: () => onMarkerTapped?.call(),
|
||||
child: SizedBox.square(
|
||||
dimension: 100,
|
||||
child: AssetMarkerIcon(
|
||||
id: marker.assetRemoteId,
|
||||
thumbhash: '',
|
||||
key: Key(marker.assetRemoteId),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -5,34 +7,36 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MapLocationPickerPage extends HookConsumerWidget {
|
||||
final Geographic initialLatLng;
|
||||
final LatLng initialLatLng;
|
||||
|
||||
const MapLocationPickerPage({super.key, this.initialLatLng = const Geographic(lat: 0, lon: 0)});
|
||||
const MapLocationPickerPage({super.key, this.initialLatLng = const LatLng(0, 0)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedLatLng = useValueNotifier<Geographic>(initialLatLng);
|
||||
final currentLatLng = useValueListenable(selectedLatLng);
|
||||
final controller = useRef<MapController?>(null);
|
||||
final selectedLatLng = useValueNotifier<LatLng>(initialLatLng);
|
||||
final controller = useRef<MapLibreMapController?>(null);
|
||||
final marker = useRef<Symbol?>(null);
|
||||
|
||||
Future<void> onStyleLoaded(StyleController style) async {
|
||||
await style.addImageFromAssets(id: 'mapMarker', asset: 'assets/location-pin.png');
|
||||
Future<void> onStyleLoaded() async {
|
||||
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
|
||||
}
|
||||
|
||||
void onEvent(MapEvent event) {
|
||||
if (event is! MapEventClick) return;
|
||||
|
||||
selectedLatLng.value = event.point;
|
||||
controller.value?.animateCamera(center: event.point);
|
||||
Future<void> onMapClick(Point<num> _, LatLng centre) async {
|
||||
selectedLatLng.value = centre;
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||
if (marker.value != null) {
|
||||
await controller.value?.updateSymbol(marker.value!, SymbolOptions(geometry: centre));
|
||||
}
|
||||
}
|
||||
|
||||
void onClose([Geographic? selected]) {
|
||||
void onClose([LatLng? selected]) {
|
||||
context.maybePop(selected);
|
||||
}
|
||||
|
||||
@@ -43,9 +47,9 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentLatLng = Geographic(lat: currentLocation.latitude, lon: currentLocation.longitude);
|
||||
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
|
||||
selectedLatLng.value = currentLatLng;
|
||||
await controller.value?.animateCamera(center: currentLatLng, zoom: 12);
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLngZoom(currentLatLng, 12));
|
||||
}
|
||||
|
||||
return MapThemeOverride(
|
||||
@@ -62,24 +66,18 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)),
|
||||
),
|
||||
child: MapLibreMap(
|
||||
options: MapOptions(
|
||||
initCenter: initialLatLng,
|
||||
initZoom: (initialLatLng.lat == 0 && initialLatLng.lon == 0) ? 1 : 12,
|
||||
initStyle: style,
|
||||
gestures: const MapGestures.all(pitch: false),
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: initialLatLng,
|
||||
zoom: (initialLatLng.latitude == 0 && initialLatLng.longitude == 0) ? 1 : 12,
|
||||
),
|
||||
styleString: style,
|
||||
onMapCreated: (mapController) => controller.value = mapController,
|
||||
onStyleLoaded: onStyleLoaded,
|
||||
onEvent: onEvent,
|
||||
layers: [
|
||||
MarkerLayer(
|
||||
points: [Feature(geometry: Point(currentLatLng))],
|
||||
iconImage: 'mapMarker',
|
||||
iconSize: 0.15,
|
||||
iconAnchor: IconAnchor.bottom,
|
||||
iconAllowOverlap: true,
|
||||
),
|
||||
],
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
onMapClick: onMapClick,
|
||||
dragEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonMargins: const Point(20, 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -119,7 +117,7 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
class _BottomBar extends StatelessWidget {
|
||||
final ValueNotifier<Geographic> selectedLatLng;
|
||||
final ValueNotifier<LatLng> selectedLatLng;
|
||||
final Function() onUseLocation;
|
||||
final Function() onGetCurrentLocation;
|
||||
|
||||
@@ -142,7 +140,8 @@ class _BottomBar extends StatelessWidget {
|
||||
const SizedBox(width: 15),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedLatLng,
|
||||
builder: (_, value, __) => Text("${value.lat.toStringAsFixed(4)}, ${value.lon.toStringAsFixed(4)}"),
|
||||
builder: (_, value, __) =>
|
||||
Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftLibraryPage extends ConsumerWidget {
|
||||
@@ -230,7 +230,7 @@ class _PlacesCollectionCard extends StatelessWidget {
|
||||
child: IgnorePointer(
|
||||
child: MapThumbnail(
|
||||
zoom: 8,
|
||||
centre: const Geographic(lat: 21.44950, lon: -157.91959),
|
||||
centre: const LatLng(21.44950, -157.91959),
|
||||
showAttribution: false,
|
||||
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
|
||||
),
|
||||
|
||||
@@ -3,11 +3,11 @@ 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/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftMapPage extends StatelessWidget {
|
||||
final Geographic? initialLocation;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
const DriftMapPage({super.key, this.initialLocation});
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftPlacePage extends StatelessWidget {
|
||||
const DriftPlacePage({super.key, this.currentLocation});
|
||||
|
||||
final Geographic? currentLocation;
|
||||
final LatLng? currentLocation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -82,7 +82,7 @@ class _Map extends StatelessWidget {
|
||||
const _Map({required this.search, this.currentLocation});
|
||||
|
||||
final ValueNotifier<String?> search;
|
||||
final Geographic? currentLocation;
|
||||
final LatLng? currentLocation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -96,7 +96,7 @@ class _Map extends StatelessWidget {
|
||||
child: MapThumbnail(
|
||||
onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
|
||||
zoom: 8,
|
||||
centre: currentLocation ?? const Geographic(lat: 21.44950, lon: -157.91959),
|
||||
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
|
||||
showAttribution: false,
|
||||
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
|
||||
),
|
||||
|
||||
@@ -698,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
|
||||
if (userPreferences.value?.tagsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.sell_outlined,
|
||||
onTap: showTagPicker,
|
||||
@@ -724,13 +724,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
|
||||
if (userPreferences.value?.ratingsEnabled ?? false) ...[
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
label: 'search_filter_star_rating'.t(context: context),
|
||||
currentFilter: ratingCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
|
||||
+4
-6
@@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widge
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class LocationDetails extends ConsumerStatefulWidget {
|
||||
const LocationDetails({super.key});
|
||||
@@ -20,7 +20,7 @@ class LocationDetails extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
MapController? _mapController;
|
||||
MapLibreMapController? _mapController;
|
||||
|
||||
String? _getLocationName(ExifInfo? exifInfo) {
|
||||
if (exifInfo == null) {
|
||||
@@ -36,16 +36,14 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onMapCreated(MapController controller) {
|
||||
void _onMapCreated(MapLibreMapController controller) {
|
||||
_mapController = controller;
|
||||
}
|
||||
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
final currentExif = current.valueOrNull;
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(
|
||||
center: Geographic(lat: currentExif.latitude!, lon: currentExif.longitude!),
|
||||
);
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ 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/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapState {
|
||||
final ThemeMode themeMode;
|
||||
final LngLatBounds bounds;
|
||||
final LatLngBounds bounds;
|
||||
final bool onlyFavorites;
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
@@ -35,7 +35,7 @@ class MapState {
|
||||
int get hashCode => bounds.hashCode;
|
||||
|
||||
MapState copyWith({
|
||||
LngLatBounds? bounds,
|
||||
LatLngBounds? bounds,
|
||||
ThemeMode? themeMode,
|
||||
bool? onlyFavorites,
|
||||
bool? includeArchived,
|
||||
@@ -64,7 +64,7 @@ class MapState {
|
||||
class MapStateNotifier extends Notifier<MapState> {
|
||||
MapStateNotifier();
|
||||
|
||||
bool setBounds(LngLatBounds bounds) {
|
||||
bool setBounds(LatLngBounds bounds) {
|
||||
if (state.bounds == bounds) {
|
||||
return false;
|
||||
}
|
||||
@@ -113,14 +113,14 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
|
||||
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
|
||||
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
|
||||
bounds: const LngLatBounds(longitudeWest: 0, longitudeEast: 0, latitudeSouth: 0, latitudeNorth: 0),
|
||||
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.
|
||||
// It should be used only after the map service provider is overridden
|
||||
final mapMarkerProvider = FutureProvider.family<Map<String, dynamic>, LngLatBounds?>((ref, bounds) async {
|
||||
final mapMarkerProvider = FutureProvider.family<Map<String, dynamic>, LatLngBounds?>((ref, bounds) async {
|
||||
final mapService = ref.watch(mapServiceProvider);
|
||||
final markers = await mapService.getMarkers(bounds);
|
||||
final features = List.filled(markers.length, const <String, dynamic>{});
|
||||
@@ -131,7 +131,7 @@ final mapMarkerProvider = FutureProvider.family<Map<String, dynamic>, LngLatBoun
|
||||
'id': marker.assetId,
|
||||
'geometry': {
|
||||
'type': 'Point',
|
||||
'coordinates': [marker.location.lon, marker.location.lat],
|
||||
'coordinates': [marker.location.longitude, marker.location.latitude],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
@@ -19,10 +20,27 @@ import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class CustomSourceProperties implements SourceProperties {
|
||||
final Map<String, dynamic> data;
|
||||
const CustomSourceProperties({required this.data});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"type": "geojson",
|
||||
"data": data,
|
||||
// "cluster": true,
|
||||
// "clusterRadius": 1,
|
||||
// "clusterMinPoints": 5,
|
||||
// "tolerance": 0.1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DriftMap extends ConsumerStatefulWidget {
|
||||
final Geographic? initialLocation;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
const DriftMap({super.key, this.initialLocation});
|
||||
|
||||
@@ -31,7 +49,7 @@ class DriftMap extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
MapController? mapController;
|
||||
MapLibreMapController? mapController;
|
||||
final _reloadMutex = AsyncMutex();
|
||||
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
||||
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
|
||||
@@ -51,7 +69,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onMapCreated(MapController controller) {
|
||||
void onMapCreated(MapLibreMapController controller) {
|
||||
mapController = controller;
|
||||
}
|
||||
|
||||
@@ -63,23 +81,43 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
await controller.style!.addSource(
|
||||
GeoJsonSource(id: MapUtils.defaultSourceId, data: jsonEncode({'type': 'FeatureCollection', 'features': []})),
|
||||
await controller.addSource(
|
||||
MapUtils.defaultSourceId,
|
||||
const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
|
||||
);
|
||||
|
||||
await controller.style!.addLayer(
|
||||
const HeatmapStyleLayer(
|
||||
id: MapUtils.defaultHeatMapLayerId,
|
||||
sourceId: MapUtils.defaultSourceId,
|
||||
paint: MapUtils.defaultHeatmapLayerPaint,
|
||||
),
|
||||
);
|
||||
if (Platform.isAndroid) {
|
||||
await controller.addCircleLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
const CircleLayerProperties(
|
||||
circleRadius: 10,
|
||||
circleColor: "rgba(150,86,34,0.7)",
|
||||
circleBlur: 1.0,
|
||||
circleOpacity: 0.7,
|
||||
circleStrokeWidth: 0.1,
|
||||
circleStrokeColor: "rgba(203,46,19,0.5)",
|
||||
circleStrokeOpacity: 0.7,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await controller.addHeatmapLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
MapUtils.defaultHeatmapLayerProperties,
|
||||
);
|
||||
}
|
||||
|
||||
_debouncer.run(() => setBounds(forceReload: true));
|
||||
controller.addListener(onMapMoved);
|
||||
}
|
||||
|
||||
void onMapEvent(MapEvent event) {
|
||||
if (event is! MapEventCameraIdle || !mounted) return;
|
||||
void onMapMoved() {
|
||||
if (mapController!.isCameraMoving || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_debouncer.run(setBounds);
|
||||
}
|
||||
@@ -98,7 +136,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = controller.getVisibleRegion();
|
||||
final bounds = await controller.getVisibleRegion();
|
||||
unawaited(
|
||||
_reloadMutex.run(() async {
|
||||
if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
|
||||
@@ -115,7 +153,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
await controller.style!.updateGeoJsonSource(id: MapUtils.defaultSourceId, data: jsonEncode(markers));
|
||||
await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers);
|
||||
}
|
||||
|
||||
Future<void> onZoomToLocation() async {
|
||||
@@ -135,9 +173,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
final controller = mapController;
|
||||
if (controller != null && location != null) {
|
||||
await controller.animateCamera(
|
||||
center: Geographic(lat: location.latitude, lon: location.longitude),
|
||||
zoom: MapUtils.mapZoomToAssetLevel,
|
||||
nativeDuration: Durations.extralong2,
|
||||
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -146,12 +183,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
_Map(
|
||||
initialLocation: widget.initialLocation,
|
||||
onMapCreated: onMapCreated,
|
||||
onMapReady: onMapReady,
|
||||
onMapEvent: onMapEvent,
|
||||
),
|
||||
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
|
||||
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
|
||||
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
|
||||
],
|
||||
@@ -160,13 +192,13 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
}
|
||||
|
||||
class _Map extends StatelessWidget {
|
||||
final Geographic? initialLocation;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady, required this.onMapEvent});
|
||||
const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady});
|
||||
|
||||
final MapCreatedCallback onMapCreated;
|
||||
|
||||
final void Function(MapController) onMapCreated;
|
||||
final VoidCallback onMapReady;
|
||||
final void Function(MapEvent) onMapEvent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -174,15 +206,16 @@ class _Map extends StatelessWidget {
|
||||
return MapThemeOverride(
|
||||
mapBuilder: (style) => style.widgetWhen(
|
||||
onData: (style) => MapLibreMap(
|
||||
options: MapOptions(
|
||||
initCenter: initialLocation ?? const Geographic(lat: 0, lon: 0),
|
||||
initZoom: initialLocation == null ? 0 : MapUtils.mapZoomToAssetLevel,
|
||||
initStyle: style,
|
||||
gestures: const MapGestures.all(rotate: false),
|
||||
),
|
||||
initialCameraPosition: initialLocation == null
|
||||
? const CameraPosition(target: LatLng(0, 0), zoom: 0)
|
||||
: CameraPosition(target: initialLocation, zoom: MapUtils.mapZoomToAssetLevel),
|
||||
compassEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoaded: (_) => onMapReady(),
|
||||
onEvent: onMapEvent,
|
||||
onStyleLoadedCallback: onMapReady,
|
||||
attributionButtonPosition: AttributionButtonPosition.topRight,
|
||||
attributionButtonMargins: const Point(8, kToolbarHeight),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapUtils {
|
||||
static final Logger _logger = Logger("MapUtils");
|
||||
@@ -12,37 +13,49 @@ class MapUtils {
|
||||
static const mapZoomToAssetLevel = 12.0;
|
||||
static const defaultSourceId = 'asset-map-markers';
|
||||
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
|
||||
static const defaultHeatmapLayerPaint = <String, Object>{
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
static var markerCompleter = Completer()..complete();
|
||||
|
||||
static const defaultCircleLayerLayerProperties = CircleLayerProperties(
|
||||
circleRadius: 10,
|
||||
circleColor: "rgba(150,86,34,0.7)",
|
||||
circleBlur: 1.0,
|
||||
circleOpacity: 0.7,
|
||||
circleStrokeWidth: 0.1,
|
||||
circleStrokeColor: "rgba(203,46,19,0.5)",
|
||||
circleStrokeOpacity: 0.7,
|
||||
);
|
||||
|
||||
static const defaultHeatmapLayerProperties = HeatmapLayerProperties(
|
||||
heatmapColor: [
|
||||
Expressions.interpolate,
|
||||
["linear"],
|
||||
["heatmap-density"],
|
||||
0.0,
|
||||
'rgba(103,58,183,0.0)',
|
||||
"rgba(103,58,183,0.0)",
|
||||
0.3,
|
||||
'rgb(103,58,183)',
|
||||
"rgb(103,58,183)",
|
||||
0.5,
|
||||
'rgb(33,149,243)',
|
||||
"rgb(33,149,243)",
|
||||
0.7,
|
||||
'rgb(76,175,79)',
|
||||
"rgb(76,175,79)",
|
||||
0.95,
|
||||
'rgb(255,235,59)',
|
||||
"rgb(255,235,59)",
|
||||
1.0,
|
||||
'rgb(255,86,34)',
|
||||
"rgb(255,86,34)",
|
||||
],
|
||||
'heatmap-intensity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
heatmapIntensity: [
|
||||
Expressions.interpolate,
|
||||
["linear"],
|
||||
[Expressions.zoom],
|
||||
0,
|
||||
0.5,
|
||||
9,
|
||||
2,
|
||||
],
|
||||
'heatmap-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
heatmapRadius: [
|
||||
Expressions.interpolate,
|
||||
["linear"],
|
||||
[Expressions.zoom],
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
@@ -50,8 +63,8 @@ class MapUtils {
|
||||
9,
|
||||
16,
|
||||
],
|
||||
'heatmap-opacity': 0.7,
|
||||
};
|
||||
heatmapOpacity: 0.7,
|
||||
);
|
||||
|
||||
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation({
|
||||
required BuildContext context,
|
||||
|
||||
@@ -74,7 +74,6 @@ class Timeline extends StatefulWidget {
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.readOnly = false,
|
||||
this.persistentBottomBar = false,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -88,7 +87,6 @@ class Timeline extends StatefulWidget {
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final bool readOnly;
|
||||
final bool persistentBottomBar;
|
||||
|
||||
@override
|
||||
State<Timeline> createState() => _TimelineState();
|
||||
@@ -145,7 +143,6 @@ class _TimelineState extends State<Timeline> {
|
||||
appBar: widget.appBar,
|
||||
bottomSheet: widget.bottomSheet,
|
||||
withScrubber: widget.withScrubber,
|
||||
persistentBottomBar: widget.persistentBottomBar,
|
||||
snapToMonth: widget.snapToMonth,
|
||||
initialScrollOffset: widget.initialScrollOffset,
|
||||
),
|
||||
@@ -176,7 +173,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
this.appBar,
|
||||
this.bottomSheet,
|
||||
this.withScrubber = true,
|
||||
this.persistentBottomBar = false,
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
});
|
||||
@@ -186,7 +182,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
final bool withScrubber;
|
||||
final bool persistentBottomBar;
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
|
||||
@@ -409,9 +404,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
|
||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
|
||||
final isBottomWidgetVisible =
|
||||
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
||||
|
||||
return PopScope(
|
||||
canPop: !isMultiSelectEnabled,
|
||||
@@ -527,7 +519,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
child: Stack(
|
||||
children: [
|
||||
timeline,
|
||||
if (isMultiSelectStatusVisible)
|
||||
if (!isSelectionMode && isMultiSelectEnabled) ...[
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
@@ -536,7 +528,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
if (widget.bottomSheet != null) widget.bottomSheet!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final assetApiRepositoryProvider = Provider(
|
||||
@@ -62,8 +62,8 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: isFavorite));
|
||||
}
|
||||
|
||||
Future<void> updateLocation(List<String> ids, Geographic location) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.lat, longitude: location.lon));
|
||||
Future<void> updateLocation(List<String> ids, LatLng location) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
|
||||
}
|
||||
|
||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
|
||||
|
||||
@@ -123,7 +123,7 @@ import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
|
||||
@@ -1226,7 +1226,7 @@ class DriftLockedFolderRoute extends PageRouteInfo<void> {
|
||||
class DriftMapRoute extends PageRouteInfo<DriftMapRouteArgs> {
|
||||
DriftMapRoute({
|
||||
Key? key,
|
||||
Geographic? initialLocation,
|
||||
LatLng? initialLocation,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftMapRoute.name,
|
||||
@@ -1252,7 +1252,7 @@ class DriftMapRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Geographic? initialLocation;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -1461,7 +1461,7 @@ class DriftPlaceDetailRouteArgs {
|
||||
class DriftPlaceRoute extends PageRouteInfo<DriftPlaceRouteArgs> {
|
||||
DriftPlaceRoute({
|
||||
Key? key,
|
||||
Geographic? currentLocation,
|
||||
LatLng? currentLocation,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftPlaceRoute.name,
|
||||
@@ -1490,7 +1490,7 @@ class DriftPlaceRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Geographic? currentLocation;
|
||||
final LatLng? currentLocation;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -2011,7 +2011,7 @@ class MainTimelineRoute extends PageRouteInfo<void> {
|
||||
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||
MapLocationPickerRoute({
|
||||
Key? key,
|
||||
Geographic initialLatLng = const Geographic(lat: 0, lon: 0),
|
||||
LatLng initialLatLng = const LatLng(0, 0),
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
MapLocationPickerRoute.name,
|
||||
@@ -2041,12 +2041,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||
class MapLocationPickerRouteArgs {
|
||||
const MapLocationPickerRouteArgs({
|
||||
this.key,
|
||||
this.initialLatLng = const Geographic(lat: 0, lon: 0),
|
||||
this.initialLatLng = const LatLng(0, 0),
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Geographic initialLatLng;
|
||||
final LatLng initialLatLng;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -2057,15 +2057,12 @@ class MapLocationPickerRouteArgs {
|
||||
/// generated route for
|
||||
/// [MapPage]
|
||||
class MapRoute extends PageRouteInfo<MapRouteArgs> {
|
||||
MapRoute({
|
||||
Key? key,
|
||||
Geographic? initialLocation,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
MapRoute.name,
|
||||
args: MapRouteArgs(key: key, initialLocation: initialLocation),
|
||||
initialChildren: children,
|
||||
);
|
||||
MapRoute({Key? key, LatLng? initialLocation, List<PageRouteInfo>? children})
|
||||
: super(
|
||||
MapRoute.name,
|
||||
args: MapRouteArgs(key: key, initialLocation: initialLocation),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'MapRoute';
|
||||
|
||||
@@ -2085,7 +2082,7 @@ class MapRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Geographic? initialLocation;
|
||||
final LatLng? initialLocation;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -2406,7 +2403,7 @@ class PinAuthRouteArgs {
|
||||
class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
|
||||
PlacesCollectionRoute({
|
||||
Key? key,
|
||||
Geographic? currentLocation,
|
||||
LatLng? currentLocation,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
PlacesCollectionRoute.name,
|
||||
@@ -2438,7 +2435,7 @@ class PlacesCollectionRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Geographic? currentLocation;
|
||||
final LatLng? currentLocation;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -22,7 +22,7 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:maplibre/maplibre.dart' as maplibre;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
final actionServiceProvider = Provider<ActionService>(
|
||||
@@ -131,12 +131,12 @@ class ActionService {
|
||||
}
|
||||
|
||||
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
|
||||
maplibre.Geographic? initialLatLng;
|
||||
maplibre.LatLng? initialLatLng;
|
||||
if (remoteIds.length == 1) {
|
||||
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
|
||||
|
||||
if (exif?.latitude != null && exif?.longitude != null) {
|
||||
initialLatLng = maplibre.Geographic(lat: exif!.latitude!, lon: exif.longitude!);
|
||||
initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
@@ -236,12 +236,12 @@ class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>?> changeLocation(List<Asset> assets, Geographic location) async {
|
||||
Future<List<Asset>?> changeLocation(List<Asset> assets, LatLng location) async {
|
||||
try {
|
||||
await updateAssets(assets, UpdateAssetDto(latitude: location.lat, longitude: location.lon));
|
||||
await updateAssets(assets, UpdateAssetDto(latitude: location.latitude, longitude: location.longitude));
|
||||
|
||||
for (var element in assets) {
|
||||
element.exifInfo = element.exifInfo?.copyWith(latitude: location.lat, longitude: location.lon);
|
||||
element.exifInfo = element.exifInfo?.copyWith(latitude: location.latitude, longitude: location.longitude);
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapService with ErrorLoggerMixin {
|
||||
final ApiService _apiService;
|
||||
@override
|
||||
final logger = Logger("MapService");
|
||||
|
||||
MapService(this._apiService);
|
||||
MapService(this._apiService) {
|
||||
_setMapUserAgentHeader();
|
||||
}
|
||||
|
||||
Future<void> _setMapUserAgentHeader() async {
|
||||
final userAgent = await getUserAgentString();
|
||||
await setHttpHeaders({'User-Agent': userAgent});
|
||||
}
|
||||
|
||||
Future<Iterable<MapMarker>> getMapMarkers({
|
||||
bool? isFavorite,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapUtils {
|
||||
const MapUtils._();
|
||||
@@ -14,53 +15,46 @@ class MapUtils {
|
||||
static const defaultSourceId = 'asset-map-markers';
|
||||
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
|
||||
|
||||
static const defaultHeatMapLayerPaint = <String, Object>{
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
static const defaultHeatMapLayerProperties = HeatmapLayerProperties(
|
||||
heatmapColor: [
|
||||
Expressions.interpolate,
|
||||
["linear"],
|
||||
["heatmap-density"],
|
||||
0.0,
|
||||
'rgba(103,58,183,0.0)',
|
||||
"rgba(103,58,183,0.0)",
|
||||
0.3,
|
||||
'rgb(103,58,183)',
|
||||
"rgb(103,58,183)",
|
||||
0.5,
|
||||
'rgb(33,149,243)',
|
||||
"rgb(33,149,243)",
|
||||
0.7,
|
||||
'rgb(76,175,79)',
|
||||
"rgb(76,175,79)",
|
||||
0.95,
|
||||
'rgb(255,235,59)',
|
||||
"rgb(255,235,59)",
|
||||
1.0,
|
||||
'rgb(255,86,34)',
|
||||
"rgb(255,86,34)",
|
||||
],
|
||||
'heatmap-intensity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0,
|
||||
0.5,
|
||||
9,
|
||||
2,
|
||||
heatmapIntensity: [
|
||||
Expressions.interpolate, ["linear"], //
|
||||
[Expressions.zoom],
|
||||
0, 0.5,
|
||||
9, 2,
|
||||
],
|
||||
'heatmap-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
8,
|
||||
9,
|
||||
16,
|
||||
heatmapRadius: [
|
||||
Expressions.interpolate, ["linear"], //
|
||||
[Expressions.zoom],
|
||||
0, 4,
|
||||
4, 8,
|
||||
9, 16,
|
||||
],
|
||||
'heatmap-opacity': 0.7,
|
||||
};
|
||||
heatmapOpacity: 0.7,
|
||||
);
|
||||
|
||||
static Map<String, dynamic> _addFeature(MapMarker marker) => {
|
||||
'type': 'Feature',
|
||||
'id': marker.assetRemoteId,
|
||||
'geometry': {
|
||||
'type': 'Point',
|
||||
'coordinates': [marker.latLng.lon, marker.latLng.lat],
|
||||
'coordinates': [marker.latLng.longitude, marker.latLng.latitude],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/share_dialog.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
void handleShareAssets(WidgetRef ref, BuildContext context, Iterable<Asset> selection) {
|
||||
showDialog(
|
||||
@@ -105,12 +105,12 @@ Future<void> handleEditDateTime(WidgetRef ref, BuildContext context, List<Asset>
|
||||
}
|
||||
|
||||
Future<void> handleEditLocation(WidgetRef ref, BuildContext context, List<Asset> selection) async {
|
||||
Geographic? initialLatLng;
|
||||
LatLng? initialLatLng;
|
||||
if (selection.length == 1) {
|
||||
final asset = selection.first;
|
||||
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
if (assetWithExif.exifInfo?.latitude != null && assetWithExif.exifInfo?.longitude != null) {
|
||||
initialLatLng = Geographic(lat: assetWithExif.exifInfo!.latitude!, lon: assetWithExif.exifInfo!.longitude!);
|
||||
initialLatLng = LatLng(assetWithExif.exifInfo!.latitude!, assetWithExif.exifInfo!.longitude!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExifMap extends StatelessWidget {
|
||||
@@ -15,7 +15,7 @@ class ExifMap extends StatelessWidget {
|
||||
// reusing this component
|
||||
final String? markerId;
|
||||
final String? markerAssetThumbhash;
|
||||
final void Function(MapController)? onMapCreated;
|
||||
final MapCreatedCallback? onMapCreated;
|
||||
|
||||
const ExifMap({
|
||||
super.key,
|
||||
@@ -66,7 +66,7 @@ class ExifMap extends StatelessWidget {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return MapThumbnail(
|
||||
centre: Geographic(lat: exifInfo.latitude ?? 0, lon: exifInfo.longitude ?? 0),
|
||||
centre: LatLng(exifInfo.latitude ?? 0, exifInfo.longitude ?? 0),
|
||||
height: 150,
|
||||
width: constraints.maxWidth,
|
||||
zoom: 12.0,
|
||||
|
||||
@@ -6,10 +6,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
Future<Geographic?> showLocationPicker({required BuildContext context, Geographic? initialLatLng}) {
|
||||
return showDialog<Geographic?>(
|
||||
Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initialLatLng}) {
|
||||
return showDialog<LatLng?>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (ctx) => _LocationPicker(initialLatLng: initialLatLng),
|
||||
@@ -17,7 +17,7 @@ Future<Geographic?> showLocationPicker({required BuildContext context, Geographi
|
||||
}
|
||||
|
||||
class _LocationPicker extends HookWidget {
|
||||
final Geographic? initialLatLng;
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
const _LocationPicker({this.initialLatLng});
|
||||
|
||||
@@ -33,9 +33,9 @@ class _LocationPicker extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitude = useState(initialLatLng?.lat ?? 0.0);
|
||||
final longitude = useState(initialLatLng?.lon ?? 0.0);
|
||||
final latlng = Geographic(lat: latitude.value, lon: longitude.value);
|
||||
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
||||
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||
final latlng = LatLng(latitude.value, longitude.value);
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
final latitudeController = useTextEditingController(text: latitude.value.toStringAsFixed(4));
|
||||
@@ -48,10 +48,10 @@ class _LocationPicker extends HookWidget {
|
||||
}, [latitude.value, longitude.value]);
|
||||
|
||||
Future<void> onMapTap() async {
|
||||
final newLatLng = await context.pushRoute<Geographic?>(MapLocationPickerRoute(initialLatLng: latlng));
|
||||
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
|
||||
if (newLatLng != null) {
|
||||
latitude.value = newLatLng.lat;
|
||||
longitude.value = newLatLng.lon;
|
||||
latitude.value = newLatLng.latitude;
|
||||
longitude.value = newLatLng.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,35 +51,36 @@ class MapAssetGrid extends HookConsumerWidget {
|
||||
final assetCache = useRef<Map<String, Asset>>({});
|
||||
|
||||
void handleMapEvents(MapEvent event) async {
|
||||
if (event is! MapAssetsInBoundsUpdated) return;
|
||||
if (event is MapAssetsInBoundsUpdated) {
|
||||
final assetIds = event.assetRemoteIds;
|
||||
final missingIds = <String>[];
|
||||
final currentAssets = <Asset>[];
|
||||
|
||||
final assetIds = event.assetRemoteIds;
|
||||
final missingIds = <String>[];
|
||||
final currentAssets = <Asset>[];
|
||||
|
||||
for (final id in assetIds) {
|
||||
final asset = assetCache.value[id];
|
||||
if (asset != null) {
|
||||
currentAssets.add(asset);
|
||||
} else {
|
||||
missingIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch missing assets
|
||||
if (missingIds.isNotEmpty) {
|
||||
final newAssets = await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
|
||||
|
||||
// Add new assets to cache and current list
|
||||
for (final asset in newAssets) {
|
||||
if (asset.remoteId != null) {
|
||||
assetCache.value[asset.remoteId!] = asset;
|
||||
for (final id in assetIds) {
|
||||
final asset = assetCache.value[id];
|
||||
if (asset != null) {
|
||||
currentAssets.add(asset);
|
||||
} else {
|
||||
missingIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assetsInBounds.value = currentAssets;
|
||||
// Only fetch missing assets
|
||||
if (missingIds.isNotEmpty) {
|
||||
final newAssets = await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
|
||||
|
||||
// Add new assets to cache and current list
|
||||
for (final asset in newAssets) {
|
||||
if (asset.remoteId != null) {
|
||||
assetCache.value[asset.remoteId!] = asset;
|
||||
currentAssets.add(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assetsInBounds.value = currentAssets;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
|
||||
|
||||
@@ -33,9 +33,13 @@ class MapBottomSheet extends HookConsumerWidget {
|
||||
final isBottomSheetOpened = useRef(false);
|
||||
|
||||
void handleMapEvents(MapEvent event) async {
|
||||
if (event is! MapCloseBottomSheet) return;
|
||||
|
||||
await sheetController.animateTo(0.1, duration: const Duration(milliseconds: 200), curve: Curves.linearToEaseOut);
|
||||
if (event is MapCloseBottomSheet) {
|
||||
await sheetController.animateTo(
|
||||
0.1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapThemePicker extends StatelessWidget {
|
||||
final ThemeMode themeMode;
|
||||
@@ -78,7 +78,7 @@ class _BorderedMapThumbnail extends StatelessWidget {
|
||||
),
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
centre: const Geographic(lat: 47, lon: 5),
|
||||
centre: const LatLng(47, 5),
|
||||
onTap: (_, __) => onThemeChange(mode),
|
||||
themeMode: mode,
|
||||
showAttribution: false,
|
||||
|
||||
@@ -84,13 +84,8 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
|
||||
data: _isDarkTheme
|
||||
? getThemeData(colorScheme: appTheme.dark, locale: locale)
|
||||
: getThemeData(colorScheme: appTheme.light, locale: locale),
|
||||
// Key on _isDarkTheme to force MapLibreMap recreation on theme change,
|
||||
// since initStyle is only applied on creation.
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(_isDarkTheme),
|
||||
child: widget.mapBuilder.call(
|
||||
ref.watch(mapStateNotifierProvider.select((v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched)),
|
||||
),
|
||||
child: widget.mapBuilder.call(
|
||||
ref.watch(mapStateNotifierProvider.select((v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
///
|
||||
@@ -13,8 +16,8 @@ import 'package:maplibre/maplibre.dart';
|
||||
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
|
||||
/// [assetMarkerRemoteId] will take precedence
|
||||
class MapThumbnail extends HookConsumerWidget {
|
||||
final Function(Offset, Geographic)? onTap;
|
||||
final Geographic centre;
|
||||
final Function(Point<double>, LatLng)? onTap;
|
||||
final LatLng centre;
|
||||
final String? assetMarkerRemoteId;
|
||||
final String? assetThumbhash;
|
||||
final bool showMarkerPin;
|
||||
@@ -23,7 +26,7 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
final double width;
|
||||
final ThemeMode? themeMode;
|
||||
final bool showAttribution;
|
||||
final void Function(MapController)? onCreated;
|
||||
final MapCreatedCallback? onCreated;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
@@ -42,19 +45,26 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useRef<MapLibreMapController?>(null);
|
||||
final styleLoaded = useState(false);
|
||||
|
||||
Future<void> onStyleLoaded(StyleController style) async {
|
||||
if (showMarkerPin) {
|
||||
await style.addImageFromAssets(id: 'mapMarker', asset: 'assets/location-pin.png');
|
||||
}
|
||||
styleLoaded.value = true;
|
||||
Future<void> onMapCreated(MapLibreMapController mapController) async {
|
||||
controller.value = mapController;
|
||||
styleLoaded.value = false;
|
||||
onCreated?.call(mapController);
|
||||
}
|
||||
|
||||
void onEvent(MapEvent event) {
|
||||
if (event is MapEventClick && onTap != null) {
|
||||
onTap!(event.screenPoint, event.point);
|
||||
Future<void> onStyleLoaded() async {
|
||||
try {
|
||||
if (showMarkerPin && controller.value != null) {
|
||||
await controller.value?.addMarkerAtLatLng(centre);
|
||||
}
|
||||
} finally {
|
||||
// Calling methods on the controller after it is disposed will throw an error
|
||||
// We do not have a way to check if the controller is disposed for now
|
||||
// https://github.com/maplibre/flutter-maplibre-gl/issues/192
|
||||
}
|
||||
styleLoaded.value = true;
|
||||
}
|
||||
|
||||
return MapThemeOverride(
|
||||
@@ -70,41 +80,37 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
width: width,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: style.widgetWhen(
|
||||
onData: (style) => MapLibreMap(
|
||||
options: MapOptions(
|
||||
initCenter: Geographic(lat: centre.lat + 0.002, lon: centre.lon),
|
||||
initZoom: zoom,
|
||||
initStyle: style,
|
||||
gestures: const MapGestures.none(),
|
||||
child: Stack(
|
||||
alignment: AlignmentGeometry.topCenter,
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => MapLibreMap(
|
||||
initialCameraPosition: CameraPosition(target: centre, zoom: zoom),
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
onMapClick: onTap,
|
||||
doubleClickZoomEnabled: false,
|
||||
dragEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
|
||||
),
|
||||
),
|
||||
onMapCreated: onCreated,
|
||||
onStyleLoaded: onStyleLoaded,
|
||||
onEvent: onEvent,
|
||||
layers: [
|
||||
if (showMarkerPin)
|
||||
MarkerLayer(
|
||||
points: [Feature(geometry: Point(centre))],
|
||||
iconImage: 'mapMarker',
|
||||
iconSize: 0.15,
|
||||
iconAnchor: IconAnchor.bottom,
|
||||
iconAllowOverlap: true,
|
||||
if (assetMarkerRemoteId != null && assetThumbhash != null)
|
||||
Container(
|
||||
width: width,
|
||||
height: height / 2,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox.square(
|
||||
dimension: height / 2.5,
|
||||
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
if (assetMarkerRemoteId != null && assetThumbhash != null)
|
||||
WidgetLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: centre,
|
||||
size: Size.square(height / 2),
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
|
||||
|
||||
class PositionedAssetMarkerIcon extends StatelessWidget {
|
||||
final Point<num> point;
|
||||
final String assetRemoteId;
|
||||
final String assetThumbhash;
|
||||
final double size;
|
||||
final int durationInMilliseconds;
|
||||
|
||||
final Function()? onTap;
|
||||
|
||||
const PositionedAssetMarkerIcon({
|
||||
required this.point,
|
||||
required this.assetRemoteId,
|
||||
required this.assetThumbhash,
|
||||
this.size = 100,
|
||||
this.durationInMilliseconds = 100,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = Platform.isIOS ? 1.0 : context.devicePixelRatio;
|
||||
return AnimatedPositioned(
|
||||
left: point.x / ratio - size / 2,
|
||||
top: point.y / ratio - size,
|
||||
duration: Duration(milliseconds: durationInMilliseconds),
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap?.call(),
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class SearchMapThumbnail extends StatelessWidget {
|
||||
const SearchMapThumbnail({super.key, this.size = 60.0});
|
||||
@@ -20,13 +20,7 @@ class SearchMapThumbnail extends StatelessWidget {
|
||||
context.pushRoute(MapRoute());
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
centre: const Geographic(lat: 47, lon: 5),
|
||||
height: size,
|
||||
width: size,
|
||||
showAttribution: false,
|
||||
),
|
||||
child: MapThumbnail(zoom: 2, centre: const LatLng(47, 5), height: size, width: size, showAttribution: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
flutter = "3.41.2"
|
||||
flutter = "3.35.7"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
+24
-144
@@ -229,10 +229,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -273,14 +273,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -784,14 +776,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
geobase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geobase
|
||||
sha256: "3a4e2eb17a7ab452dda78fb45ee498a3ab02469ded749a5f4a9abea21fc95919"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
geoclue:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -888,14 +872,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1149,14 +1125,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lists
|
||||
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1205,78 +1173,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
maplibre:
|
||||
maplibre_gl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: maplibre
|
||||
sha256: "03aad98086ef8e24caf9abcbbacf43f7ceb6267a6b914d907f57fb05ccb65e09"
|
||||
name: maplibre_gl
|
||||
sha256: "5c7b1008396b2a321bada7d986ed60f9423406fbc7bd16f7ce91b385dfa054cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4"
|
||||
maplibre_android:
|
||||
version: "0.22.0"
|
||||
maplibre_gl_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: maplibre_android
|
||||
sha256: be8a9c29b20c10f4b2207790e8ab35489955a29cb69df10e38bdf993b44f1547
|
||||
name: maplibre_gl_platform_interface
|
||||
sha256: "08ee0a2d0853ea945a0ab619d52c0c714f43144145cd67478fc6880b52f37509"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4"
|
||||
maplibre_ios:
|
||||
version: "0.22.0"
|
||||
maplibre_gl_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: maplibre_ios
|
||||
sha256: "3e261d99697cc191e64ceb256acec5d96662d429059057bb6c1740dd11eaa7c3"
|
||||
name: maplibre_gl_web
|
||||
sha256: "2b13d4b1955a9a54e38a718f2324e56e4983c080fc6de316f6f4b5458baacb58"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4"
|
||||
maplibre_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: maplibre_platform_interface
|
||||
sha256: "7d912d82d41a31daed4e91a243a1324f483f7ffbf3671c229b98328beab854b4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4"
|
||||
maplibre_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: maplibre_web
|
||||
sha256: "7e79427cdb0098c1e054fb90f5f35f4538c69e8d170c0e92f6abe7c61e106461"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4"
|
||||
version: "0.22.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1293,14 +1237,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1541,38 +1477,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointer_interceptor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor
|
||||
sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1+2"
|
||||
pointer_interceptor_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor_ios
|
||||
sha256: "03c5fa5896080963ab4917eeffda8d28c90f22863a496fb5ba13bc10943e40e4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1+1"
|
||||
pointer_interceptor_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor_platform_interface
|
||||
sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.0+1"
|
||||
pointer_interceptor_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor_web
|
||||
sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.3"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1597,14 +1501,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.3"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2014,10 +1910,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.6"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2058,14 +1954,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2274,14 +2162,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wkt_parser
|
||||
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
worker_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2323,5 +2203,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: "3.41.2"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.35.7"
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ publish_to: 'none'
|
||||
version: 2.5.6+3037
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: 3.41.2
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.35.7
|
||||
|
||||
dependencies:
|
||||
async: ^2.13.0
|
||||
@@ -52,7 +52,7 @@ dependencies:
|
||||
isar_community_flutter_libs: 3.3.0-dev.3
|
||||
local_auth: ^2.3.0
|
||||
logging: ^1.3.0
|
||||
maplibre: ^0.3.4
|
||||
maplibre_gl: ^0.22.0
|
||||
|
||||
native_video_player:
|
||||
git:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:maplibre/maplibre.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -85,9 +85,9 @@ void main() {
|
||||
expect(receivedDatetime.every((d) => d == dateTime), isTrue);
|
||||
});
|
||||
|
||||
test("asset is updated with Geographic", () async {
|
||||
test("asset is updated with LatLng", () async {
|
||||
final assets = [AssetStub.image1, AssetStub.image2];
|
||||
final latLng = const Geographic(lat: 37.7749, lon: -122.4194);
|
||||
final latLng = const LatLng(37.7749, -122.4194);
|
||||
await sut.changeLocation(assets, latLng);
|
||||
|
||||
verify(() => assetsApi.updateAssets(any())).called(1);
|
||||
@@ -95,7 +95,7 @@ void main() {
|
||||
upsertExifCallback.called(1);
|
||||
final receivedAssets = upsertExifCallback.captured.firstOrNull as List<Object>? ?? [];
|
||||
final receivedCoords = receivedAssets.cast<Asset>().map(
|
||||
(a) => Geographic(lat: a.exifInfo?.latitude ?? 0, lon: a.exifInfo?.longitude ?? 0),
|
||||
(a) => LatLng(a.exifInfo?.latitude ?? 0, a.exifInfo?.longitude ?? 0),
|
||||
);
|
||||
expect(receivedCoords.every((l) => l == latLng), isTrue);
|
||||
});
|
||||
|
||||
Generated
+10
-4
@@ -11,7 +11,7 @@ overrides:
|
||||
|
||||
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
|
||||
|
||||
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
|
||||
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
|
||||
|
||||
importers:
|
||||
|
||||
@@ -343,6 +343,9 @@ importers:
|
||||
'@extism/extism':
|
||||
specifier: 2.0.0-rc13
|
||||
version: 2.0.0-rc13
|
||||
'@immich/walkrs':
|
||||
specifier: ^0.0.13
|
||||
version: 0.0.13
|
||||
'@nestjs/bullmq':
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)
|
||||
@@ -454,9 +457,6 @@ importers:
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.1
|
||||
fast-glob:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.3
|
||||
fluent-ffmpeg:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.3
|
||||
@@ -3023,6 +3023,10 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
'@immich/walkrs@0.0.13':
|
||||
resolution: {integrity: sha512-qKDoXFgy3d2Z7SIJBn25BcNyQnPLAp2zZEBcewpWxG5+qAXDPi3M3sweJ9qJ11Eha+YlmpUO3c8yd5CCBeq96A==}
|
||||
engines: {pnpm: '>=10.0.0'}
|
||||
|
||||
'@inquirer/ansi@1.0.2':
|
||||
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -14835,6 +14839,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
'@immich/walkrs@0.0.13': {}
|
||||
|
||||
'@inquirer/ansi@1.0.2': {}
|
||||
|
||||
'@inquirer/checkbox@4.3.2(@types/node@24.10.13)':
|
||||
|
||||
+26
-17
@@ -9,6 +9,9 @@ packages:
|
||||
- plugins
|
||||
- web
|
||||
- .github
|
||||
|
||||
dedupePeerDependents: false
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
- '@parcel/watcher'
|
||||
@@ -25,42 +28,48 @@ ignoredBuiltDependencies:
|
||||
- protobufjs
|
||||
- ssh2
|
||||
- utimes
|
||||
|
||||
injectWorkspacePackages: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- '@tailwindcss/oxide'
|
||||
- bcrypt
|
||||
|
||||
overrides:
|
||||
canvas: 2.11.2
|
||||
sharp: ^0.34.5
|
||||
|
||||
packageExtensions:
|
||||
nestjs-kysely:
|
||||
'@immich/ui':
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
nestjs-otel:
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
tailwindcss: '>=4.1'
|
||||
'@photo-sphere-viewer/equirectangular-video-adapter':
|
||||
dependencies:
|
||||
three: '*'
|
||||
'@photo-sphere-viewer/video-plugin':
|
||||
dependencies:
|
||||
three: '*'
|
||||
sharp:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
'@immich/ui':
|
||||
dependencies:
|
||||
tailwindcss: '>=4.1'
|
||||
tailwind-variants:
|
||||
dependencies:
|
||||
tailwindcss: '>=4.1'
|
||||
bcrypt:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
dedupePeerDependents: false
|
||||
nestjs-kysely:
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
nestjs-otel:
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
sharp:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
tailwind-variants:
|
||||
dependencies:
|
||||
tailwindcss: '>=4.1'
|
||||
|
||||
preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
|
||||
shamefullyHoist: false
|
||||
|
||||
verifyDepsBeforeRun: install
|
||||
|
||||
@@ -27,14 +27,16 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
|
||||
FROM dev AS dev-container-server
|
||||
|
||||
RUN apt-get update --allow-releaseinfo-change && \
|
||||
apt-get install inetutils-ping openjdk-21-jre-headless \
|
||||
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
|
||||
vim nano curl \
|
||||
-y --no-install-recommends --fix-missing
|
||||
|
||||
RUN mkdir -p /workspaces && \
|
||||
ln -s /usr/src/app /workspaces/immich
|
||||
RUN usermod -aG sudo node && \
|
||||
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
mkdir -p /workspaces/immich
|
||||
|
||||
COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
RUN chown node:node -R /workspaces
|
||||
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
|
||||
WORKDIR /workspaces/immich
|
||||
|
||||
@@ -59,7 +61,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
|
||||
# Flutter SDK
|
||||
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
||||
ENV FLUTTER_CHANNEL="stable"
|
||||
ENV FLUTTER_VERSION="3.41.2"
|
||||
ENV FLUTTER_VERSION="3.35.7"
|
||||
ENV FLUTTER_HOME=/flutter
|
||||
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
||||
|
||||
|
||||
+1
-1
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/walkrs": "^0.0.13",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
@@ -72,7 +73,6 @@
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^34.3.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
|
||||
@@ -54,16 +54,12 @@ export class UpdateLibraryDto {
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface CrawlOptionsDto {
|
||||
pathsToCrawl: string[];
|
||||
export interface WalkOptionsDto {
|
||||
pathsToWalk: string[];
|
||||
includeHidden?: boolean;
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface WalkOptionsDto extends CrawlOptionsDto {
|
||||
take: number;
|
||||
}
|
||||
|
||||
export class ValidateLibraryDto {
|
||||
@ApiPropertyOptional({ description: 'Import paths to validate (max 128)' })
|
||||
@Optional()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import mockfs from 'mock-fs';
|
||||
import { CrawlOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { automock } from 'test/utils';
|
||||
|
||||
interface Test {
|
||||
test: string;
|
||||
options: CrawlOptionsDto;
|
||||
files: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
const tests: Test[] = [
|
||||
{
|
||||
test: 'should return empty when crawling an empty path list',
|
||||
options: {
|
||||
pathsToCrawl: [],
|
||||
},
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.tif'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension without case sensitivity',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.TIF'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by folder',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/raw/image.jpg': false,
|
||||
'/photos/raw2/image.jpg': true,
|
||||
'/photos/folder/raw/image.jpg': false,
|
||||
'/photos/crawl/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl multiple paths',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/images/image2.jpg': true,
|
||||
'/albums/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path without trailing slash',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/subfolder/image1.jpg': true,
|
||||
'/photos/subfolder/image2.jpg': true,
|
||||
'/image1.jpg': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should filter file extensions',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.txt': false,
|
||||
'/photos/1': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should include photo and video extensions',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/', '/videos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.jpeg': true,
|
||||
'/photos/image.heic': true,
|
||||
'/photos/image.heif': true,
|
||||
'/photos/image.png': true,
|
||||
'/photos/image.gif': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.tiff': true,
|
||||
'/photos/image.webp': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.nef': true,
|
||||
'/videos/video.mp4': true,
|
||||
'/videos/video.mov': true,
|
||||
'/videos/video.webm': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should check file extensions without case sensitivity',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.Jpg': true,
|
||||
'/photos/image.jpG': true,
|
||||
'/photos/image.JPG': true,
|
||||
'/photos/image.jpEg': true,
|
||||
'/photos/image.TIFF': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.NEF': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should normalize the path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/1/../2'],
|
||||
},
|
||||
files: {
|
||||
'/photos/1/image.jpg': false,
|
||||
'/photos/2/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should return absolute paths',
|
||||
options: {
|
||||
pathsToCrawl: ['photos'],
|
||||
},
|
||||
files: {
|
||||
[`${cwd}/photos/1.jpg`]: true,
|
||||
[`${cwd}/photos/2.jpg`]: true,
|
||||
[`/photos/3.jpg`]: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support special characters in paths',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos (new)'],
|
||||
},
|
||||
files: {
|
||||
['/photos (new)/1.jpg']: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe(StorageRepository.name, () => {
|
||||
let sut: StorageRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
sut = new StorageRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
});
|
||||
|
||||
describe('crawl', () => {
|
||||
for (const { test, options, files } of tests) {
|
||||
it(test, async () => {
|
||||
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
|
||||
|
||||
const actual = await sut.crawl(options);
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([file]) => file);
|
||||
|
||||
expect(actual.toSorted()).toEqual(expected.toSorted());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
import { createGunzip, createGzip } from 'node:zlib';
|
||||
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
@@ -198,54 +198,22 @@ export class StorageRepository {
|
||||
};
|
||||
}
|
||||
|
||||
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
|
||||
if (pathsToCrawl.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<WalkItem[], void, unknown> {
|
||||
const { pathsToWalk, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToWalk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
|
||||
const { walk } = await import('@immich/walkrs');
|
||||
|
||||
return glob(globbedPaths, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
onlyFiles: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
yield* walk({
|
||||
paths: pathsToWalk.map((p) => path.resolve(p)),
|
||||
includeHidden: includeHidden ?? false,
|
||||
exclusionPatterns,
|
||||
extensions: mimeTypes.getSupportedFileExtensions(),
|
||||
});
|
||||
}
|
||||
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToCrawl.length === 0) {
|
||||
async function* emptyGenerator() {}
|
||||
return emptyGenerator();
|
||||
}
|
||||
|
||||
const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
|
||||
|
||||
const stream = globStream(globbedPaths, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
onlyFiles: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
});
|
||||
|
||||
let batch: string[] = [];
|
||||
for await (const value of stream) {
|
||||
batch.push(value.toString());
|
||||
if (batch.length === walkOptions.take) {
|
||||
yield batch;
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
yield batch;
|
||||
}
|
||||
}
|
||||
|
||||
watch(paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>) {
|
||||
const watcher = chokidar.watch(paths, options);
|
||||
|
||||
@@ -257,10 +225,4 @@ export class StorageRepository {
|
||||
|
||||
return () => watcher.close();
|
||||
}
|
||||
|
||||
private asGlob(pathToCrawl: string): string {
|
||||
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
|
||||
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
|
||||
return `${escapedPath}/**/${extensions}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { defaults, SystemConfig } from 'src/config';
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { mapLibrary } from 'src/dtos/library.dto';
|
||||
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
|
||||
import { LibraryService } from 'src/services/library.service';
|
||||
@@ -14,10 +13,6 @@ import { factory, newDate, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { vitest } from 'vitest';
|
||||
|
||||
async function* mockWalk() {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
}
|
||||
|
||||
describe(LibraryService.name, () => {
|
||||
let sut: LibraryService;
|
||||
|
||||
@@ -165,7 +160,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(mockWalk);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
@@ -201,16 +200,20 @@ describe(LibraryService.name, () => {
|
||||
});
|
||||
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
|
||||
await sut.handleQueueSyncFiles({ id: library.id });
|
||||
|
||||
expect(mocks.storage.walk).toHaveBeenCalledWith({
|
||||
pathsToCrawl: [library.importPaths[1]],
|
||||
pathsToWalk: [library.importPaths[1]],
|
||||
exclusionPatterns: [],
|
||||
includeHidden: false,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,7 +223,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(mockWalk);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
@@ -242,33 +249,6 @@ describe(LibraryService.name, () => {
|
||||
|
||||
await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.Skipped);
|
||||
});
|
||||
|
||||
it('should ignore import paths that do not exist', async () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.storage.stat.mockImplementation((path): Promise<Stats> => {
|
||||
if (path === library.importPaths[0]) {
|
||||
const error = { code: 'ENOENT' } as any;
|
||||
throw error;
|
||||
}
|
||||
return Promise.resolve({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
});
|
||||
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
|
||||
await sut.handleQueueSyncFiles({ id: library.id });
|
||||
|
||||
expect(mocks.storage.walk).toHaveBeenCalledWith({
|
||||
pathsToCrawl: [library.importPaths[1]],
|
||||
exclusionPatterns: [],
|
||||
includeHidden: false,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSyncAssets', () => {
|
||||
@@ -276,7 +256,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
@@ -294,7 +278,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
@@ -309,7 +297,11 @@ describe(LibraryService.name, () => {
|
||||
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { R_OK } from 'node:constants';
|
||||
import { Stats } from 'node:fs';
|
||||
import path, { basename, isAbsolute, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
@@ -247,9 +248,11 @@ export class LibraryService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const newPaths = await this.assetRepository.filterNewExternalAssetPaths(library.id, job.paths);
|
||||
|
||||
const assetImports: Insertable<AssetTable>[] = [];
|
||||
await Promise.all(
|
||||
job.paths.map((path) =>
|
||||
newPaths.map((path) =>
|
||||
this.processEntity(path, library.ownerId, job.libraryId)
|
||||
.then((asset) => assetImports.push(asset))
|
||||
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
|
||||
@@ -394,6 +397,7 @@ export class LibraryService extends BaseService {
|
||||
|
||||
private async processEntity(filePath: string, ownerId: string, libraryId: string) {
|
||||
const assetPath = path.normalize(filePath);
|
||||
|
||||
const stat = await this.storageRepository.stat(assetPath);
|
||||
|
||||
return {
|
||||
@@ -636,42 +640,56 @@ export class LibraryService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const pathsOnDisk = this.storageRepository.walk({
|
||||
pathsToCrawl: validImportPaths,
|
||||
includeHidden: false,
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
|
||||
let importCount = 0;
|
||||
let crawlCount = 0;
|
||||
|
||||
this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`);
|
||||
|
||||
for await (const pathBatch of pathsOnDisk) {
|
||||
crawlCount += pathBatch.length;
|
||||
const paths = await this.assetRepository.filterNewExternalAssetPaths(library.id, pathBatch);
|
||||
const fileWalker = this.storageRepository.walk({
|
||||
pathsToWalk: validImportPaths,
|
||||
includeHidden: false, // TODO: make this configurable?
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
});
|
||||
|
||||
if (paths.length > 0) {
|
||||
importCount += paths.length;
|
||||
const walkStart = Date.now();
|
||||
let progressCounter = 0;
|
||||
let lastLoggedMilestone = 0;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncFiles,
|
||||
data: {
|
||||
libraryId: library.id,
|
||||
paths,
|
||||
progressCounter: crawlCount,
|
||||
},
|
||||
});
|
||||
for await (const walkItems of fileWalker) {
|
||||
const paths: string[] = [];
|
||||
for (const item of walkItems) {
|
||||
if (item.type === 'error') {
|
||||
this.logger.warn(`Error walking ${item.path ?? 'unknown path'}: ${item.message} for library ${library.id}`);
|
||||
} else {
|
||||
paths.push(item.path);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Crawled ${crawlCount} file(s) so far: ${paths.length} of current batch of ${pathBatch.length} will be imported to library ${library.id}...`,
|
||||
);
|
||||
if (paths.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
progressCounter += paths.length;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncFiles,
|
||||
data: {
|
||||
libraryId: library.id,
|
||||
paths,
|
||||
progressCounter,
|
||||
},
|
||||
});
|
||||
|
||||
const currentMilestone = Math.floor(progressCounter / 100_000);
|
||||
// Log every 100k files found to give some feedback on progress for large libraries
|
||||
if (currentMilestone > lastLoggedMilestone) {
|
||||
const roundedCount = currentMilestone * 100_000;
|
||||
this.logger.log(
|
||||
`Disk walk found ${roundedCount} file(s) so far (${((Date.now() - walkStart) / 1000).toFixed(2)}s elapsed) for library ${library.id}...`,
|
||||
);
|
||||
lastLoggedMilestone = currentMilestone;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Finished disk crawl, ${crawlCount} file(s) found on disk and queued ${importCount} file(s) for import into ${library.id}`,
|
||||
`Finished disk walk, ${progressCounter} file(s) found on disk in ${((Date.now() - walkStart) / 1000).toFixed(2)}s for library ${library.id}`,
|
||||
);
|
||||
|
||||
await this.libraryRepository.update(job.id, { refreshedAt: new Date() });
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { WalkError, WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
|
||||
import { Kysely } from 'kysely';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import { WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
interface Test {
|
||||
test: string;
|
||||
options: WalkOptionsDto;
|
||||
files: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const createTestFiles = async (basePath: string, files: string[]) => {
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fullPath = path.join(basePath, file.replace(/^\//, ''));
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fs.writeFile(fullPath, '');
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const tests: Test[] = [
|
||||
{
|
||||
test: 'should return empty when walking an empty path list',
|
||||
options: {
|
||||
pathsToWalk: [],
|
||||
},
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should walk a single path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.tif'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension without case sensitivity',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.TIF'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
'/photos/image.tIf': false,
|
||||
'/photos/image.TIF': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by folder',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/raw/image.jpg': false,
|
||||
'/photos/raw2/image.jpg': true,
|
||||
'/photos/folder/raw/image.jpg': false,
|
||||
'/photos/walk/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should walk multiple paths',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/', '/images/', '/albums/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/images/image2.jpg': true,
|
||||
'/albums/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should walk a single path without trailing slash',
|
||||
options: {
|
||||
pathsToWalk: ['/photos'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should walk a single path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/subfolder/image1.jpg': true,
|
||||
'/photos/subfolder/image2.jpg': true,
|
||||
'/image1.jpg': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should filter file extensions',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.txt': false,
|
||||
'/photos/1': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should include photo and video extensions',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/', '/videos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.jpeg': true,
|
||||
'/photos/image.heic': true,
|
||||
'/photos/image.heif': true,
|
||||
'/photos/image.png': true,
|
||||
'/photos/image.gif': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.tiff': true,
|
||||
'/photos/image.webp': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.nef': true,
|
||||
'/videos/video.mp4': true,
|
||||
'/videos/video.mov': true,
|
||||
'/videos/video.webm': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should check file extensions without case sensitivity',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.Jpg': true,
|
||||
'/photos/image.jpG': true,
|
||||
'/photos/image.JPG': true,
|
||||
'/photos/image.jpEg': true,
|
||||
'/photos/image.TIFF': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.NEF': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should normalize the path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/1/../2'],
|
||||
},
|
||||
files: {
|
||||
'/photos/1/image.jpg': false,
|
||||
'/photos/2/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support special characters in paths',
|
||||
options: {
|
||||
pathsToWalk: ['/photos (new)'],
|
||||
},
|
||||
files: {
|
||||
['/photos (new)/1.jpg']: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { sut: ctx.get(StorageRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(StorageRepository.name, () => {
|
||||
let sut: StorageRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut } = setup());
|
||||
});
|
||||
|
||||
describe('walk', () => {
|
||||
for (const { test, options, files } of tests) {
|
||||
describe(test, () => {
|
||||
const fileList = Object.keys(files);
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'immich-storage-test-'));
|
||||
await createTestFiles(tempDir, fileList);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns expected files', async () => {
|
||||
const adjustedOptions = {
|
||||
...options,
|
||||
pathsToWalk: options.pathsToWalk.map((p) => path.join(tempDir, p.replace(/^\//, ''))),
|
||||
};
|
||||
|
||||
const actual: string[] = [];
|
||||
for await (const batch of sut.walk(adjustedOptions)) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'entry') {
|
||||
actual.push(item.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([file]) => path.join(tempDir, file.replace(/^\//, '')));
|
||||
|
||||
expect(actual.toSorted()).toEqual(expected.toSorted());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle access denied errors gracefully', async () => {
|
||||
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
|
||||
const restrictedDir = join(testDir, 'restricted');
|
||||
const restrictedFile = join(restrictedDir, 'file.jpg');
|
||||
const accessibleFile = join(testDir, 'accessible.jpg');
|
||||
|
||||
try {
|
||||
// Create test directory structure
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.writeFile(accessibleFile, 'accessible content');
|
||||
await fs.writeFile(restrictedFile, 'restricted content');
|
||||
|
||||
// Remove all permissions from restricted directory to simulate access denied
|
||||
await fs.chmod(restrictedDir, 0o000);
|
||||
|
||||
const actual: string[] = [];
|
||||
const errors: WalkItem[] = [];
|
||||
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'entry') {
|
||||
actual.push(item.path);
|
||||
} else {
|
||||
errors.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should successfully walk accessible file but skip restricted directory
|
||||
expect(actual).toContain(accessibleFile);
|
||||
expect(actual).not.toContain(restrictedFile);
|
||||
// Should have encountered an error for the restricted directory
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors.some((e) => e.type === 'error' && e.message?.includes('restricted'))).toBe(true);
|
||||
} finally {
|
||||
// Cleanup: restore permissions before deletion
|
||||
try {
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
} catch {
|
||||
// Ignore errors if directory was already deleted or permissions cannot be restored
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error details for access denied paths', async () => {
|
||||
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
|
||||
const restrictedDir = join(testDir, 'restricted');
|
||||
const restrictedFile = join(restrictedDir, 'file.jpg');
|
||||
const accessibleFile = join(testDir, 'accessible.jpg');
|
||||
|
||||
try {
|
||||
// Create test directory structure
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.writeFile(accessibleFile, 'accessible content');
|
||||
await fs.writeFile(restrictedFile, 'restricted content');
|
||||
|
||||
// Remove all permissions from restricted directory to simulate access denied
|
||||
await fs.chmod(restrictedDir, 0o000);
|
||||
|
||||
const errors: WalkError[] = [];
|
||||
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'error') {
|
||||
errors.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have error details including path and message
|
||||
expect(errors.length).toBe(1);
|
||||
const restrictedError = errors.find((e) => e.type === 'error' && e.message?.includes('restricted'));
|
||||
expect(restrictedError).toBeDefined();
|
||||
expect(restrictedError?.type).toBe('error');
|
||||
expect(restrictedError?.message).toBeDefined();
|
||||
} finally {
|
||||
// Cleanup: restore permissions before deletion
|
||||
try {
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
} catch {
|
||||
// Ignore errors if directory was already deleted or permissions cannot be restored
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,8 +68,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
readdir: vitest.fn(),
|
||||
realpath: vitest.fn().mockImplementation((filepath: string) => Promise.resolve(filepath)),
|
||||
stat: vitest.fn(),
|
||||
crawl: vitest.fn(),
|
||||
walk: vitest.fn().mockImplementation(async function* () {}),
|
||||
walk: vitest.fn(),
|
||||
rename: vitest.fn(),
|
||||
copyFile: vitest.fn(),
|
||||
utimes: vitest.fn(),
|
||||
|
||||
@@ -223,7 +223,6 @@
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
data-selected={selected ? true : undefined}
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user