Compare commits

..

25 Commits

Author SHA1 Message Date
idubnori 7c3f1753f1 feat: add reorder buttons action button and integrate into viewer kebab menu 2025-12-11 01:45:45 +09:00
idubnori 185134953d refactor: remove redundant "open_bottom_sheet_info" 2025-12-11 00:49:39 +09:00
idubnori c136b5f6ee Merge branch 'main' into feature/rearrange-buttons-2 2025-12-11 00:46:59 +09:00
idubnori a604a0ad6d refactor: replace custom _ReorderableGrid with ReorderableDragDropGrid for improved drag-and-drop functionality 2025-12-10 14:00:36 +09:00
idubnori a84f4fc1bd refactor: remove debug print statements from _ReorderableGrid drag-and-drop handling 2025-12-10 13:47:24 +09:00
idubnori f91d5d7da8 fix: improve drag-and-drop handling in _ReorderableGrid with enhanced visual feedback and snap animation 2025-12-10 13:41:04 +09:00
idubnori 1517385704 refactor: enhance drag-and-drop functionality in _ReorderableGrid with visual feedback 2025-12-10 09:46:19 +09:00
idubnori 598e856322 refactor: replace flutter_reorderable_grid_view with custom _ReorderableGrid implementation 2025-12-10 09:11:57 +09:00
idubnori 17361d189c refactor: clean up 2025-12-10 01:58:07 +09:00
idubnori 7473b959dc refactor: proper layer archtecture 2025-12-10 01:06:07 +09:00
idubnori f874c12bee fix: update JSON serialization for ActionButtonType and improve type safety 2025-12-09 15:53:18 +09:00
idubnori 2d4e901c55 refactor: update viewer quick action order handling and refactor related utilities 2025-12-09 14:56:57 +09:00
idubnori 1e5c3d7d37 Merge branch 'main' into feature/rearrange-buttons-2 2025-12-09 12:04:29 +09:00
idubnori 3700f9980f revert: viewer_kebab 2025-12-09 11:48:40 +09:00
idubnori 7f3386c8d0 feat: add configurator button to viewer kebab menu 2025-12-04 23:47:41 +09:00
idubnori be9e632efb Revert "chore(mobile): add table schemas to swift (#23749)"
This reverts commit 9e2208b8dd.
2025-12-04 19:32:27 +09:00
idubnori 80c1771cb2 Merge branch 'feature/kebab-menu-2' into feature/rearrange-buttons-2 2025-12-04 19:30:52 +09:00
idubnori c7c929b3b5 feat: revert exisitng buttons, adjust label name 2025-12-04 14:16:01 +09:00
idubnori 72f18183a1 Merge remote-tracking branch 'upstream/main' into feature/kebab-menu-2 2025-12-04 13:39:00 +09:00
idubnori 7f9ba91c8d feat(mobile): implement viewer kebab menu with about option 2025-11-25 22:30:44 +09:00
idubnori 6c07915f84 feat: configurable AddActionButton 2025-11-17 09:41:00 +09:00
idubnori d4df41dc38 Merge remote-tracking branch 'origin/main' into feature/rearrange-buttons-2 2025-11-17 09:35:03 +09:00
idubnori 0eae657611 refactor: rename to QuickActionConfigurator 2025-11-05 23:14:24 +09:00
idubnori 74f90fe944 fix: dcm analyze fails 2025-11-05 21:54:01 +09:00
idubnori eb7813047b feat(mobile): init of add quick action configurator and settings for viewer actions 2025-11-05 21:35:21 +09:00
88 changed files with 2116 additions and 3489 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: 'zulu'
java-version: '17'
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+4 -4
View File
@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,7 +68,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -36,7 +36,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -80,7 +80,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+13 -13
View File
@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -121,7 +121,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -168,7 +168,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -210,7 +210,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -254,7 +254,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -292,7 +292,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -340,7 +340,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -481,7 +481,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -617,7 +617,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -668,7 +668,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -730,7 +730,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+1 -1
View File
@@ -127,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -24,9 +24,6 @@ server {
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# increase body buffer to avoid limiting upload speed
client_body_buffer_size 1024k;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+1 -1
View File
@@ -52,7 +52,7 @@ Password login has been enabled.
Disable Maintenance Mode
```
immich-admin disable-maintenance-mode
immich-admin disable-maintenace-mode
Maintenance mode has been disabled.
```
+3 -2
View File
@@ -1113,8 +1113,6 @@
"failed_to_load_folder": "Failed to load folder",
"favorite": "Favorite",
"favorite_action_prompt": "{count} added to Favorites",
"favorite_locations": "Favorite Locations",
"favorite_locations_not_found": "No favorite locations saved",
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
"favorites": "Favorites",
"favorites_page_no_favorites": "No favorite assets found",
@@ -1669,6 +1667,8 @@
"purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}",
"quick_actions_settings_description": "Drag to rearrange buttons. Up to {count} available buttons are displayed in order.",
"quick_actions_settings_title": "Button order settings",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}",
@@ -1729,6 +1729,7 @@
"removed_photo_from_memory": "Removed photo from memory",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
"reorder_buttons": "Reorder buttons",
"repair": "Repair",
"repair_no_results_message": "Untracked and missing files will show up here",
"replace_with_upload": "Replace with upload",
@@ -32,9 +32,6 @@
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -156,13 +153,6 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -170,9 +160,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -267,7 +254,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
@@ -355,7 +341,6 @@
fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;
productName = Runner;
@@ -434,10 +419,6 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -1220,43 +1201,6 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pointfreeco/sqlite-data";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
};
};
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-http-structured-headers.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.5.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
FEE084F72EC172460045228E /* SQLiteData */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
productName = SQLiteData;
};
FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = RawStructuredFieldValues;
};
FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = StructuredFieldValues;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,168 +0,0 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
"version" : "1.0.3"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
"version" : "7.8.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/sqlite-data",
"state" : {
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
"version" : "1.3.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
"version" : "1.7.2"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
"version" : "1.10.0"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
"version" : "1.5.0"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
"version" : "1.1.1"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
"version" : "2.0.9"
}
},
{
"identity" : "swift-sharing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{
"identity" : "swift-structured-queries",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
"version" : "0.25.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"version" : "602.0.0"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
"version" : "1.7.0"
}
}
],
"version" : 3
}
+2 -1
View File
@@ -137,7 +137,8 @@
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
-177
View File
@@ -1,177 +0,0 @@
import SQLiteData
struct Endpoint: Codable {
let url: URL
let status: Status
enum Status: String, Codable {
case loading, valid, error, unknown
}
}
enum StoreKey: Int, CaseIterable, QueryBindable {
// MARK: - Int
case _version = 0
static let version = Typed<Int>(rawValue: ._version)
case _deviceIdHash = 3
static let deviceIdHash = Typed<Int>(rawValue: ._deviceIdHash)
case _backupTriggerDelay = 8
static let backupTriggerDelay = Typed<Int>(rawValue: ._backupTriggerDelay)
case _tilesPerRow = 103
static let tilesPerRow = Typed<Int>(rawValue: ._tilesPerRow)
case _groupAssetsBy = 105
static let groupAssetsBy = Typed<Int>(rawValue: ._groupAssetsBy)
case _uploadErrorNotificationGracePeriod = 106
static let uploadErrorNotificationGracePeriod = Typed<Int>(rawValue: ._uploadErrorNotificationGracePeriod)
case _thumbnailCacheSize = 110
static let thumbnailCacheSize = Typed<Int>(rawValue: ._thumbnailCacheSize)
case _imageCacheSize = 111
static let imageCacheSize = Typed<Int>(rawValue: ._imageCacheSize)
case _albumThumbnailCacheSize = 112
static let albumThumbnailCacheSize = Typed<Int>(rawValue: ._albumThumbnailCacheSize)
case _selectedAlbumSortOrder = 113
static let selectedAlbumSortOrder = Typed<Int>(rawValue: ._selectedAlbumSortOrder)
case _logLevel = 115
static let logLevel = Typed<Int>(rawValue: ._logLevel)
case _mapRelativeDate = 119
static let mapRelativeDate = Typed<Int>(rawValue: ._mapRelativeDate)
case _mapThemeMode = 124
static let mapThemeMode = Typed<Int>(rawValue: ._mapThemeMode)
// MARK: - String
case _assetETag = 1
static let assetETag = Typed<String>(rawValue: ._assetETag)
case _currentUser = 2
static let currentUser = Typed<String>(rawValue: ._currentUser)
case _deviceId = 4
static let deviceId = Typed<String>(rawValue: ._deviceId)
case _accessToken = 11
static let accessToken = Typed<String>(rawValue: ._accessToken)
case _serverEndpoint = 12
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
case _sslClientCertData = 15
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
case _sslClientPasswd = 16
static let sslClientPasswd = Typed<String>(rawValue: ._sslClientPasswd)
case _themeMode = 102
static let themeMode = Typed<String>(rawValue: ._themeMode)
case _customHeaders = 127
static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders)
case _primaryColor = 128
static let primaryColor = Typed<String>(rawValue: ._primaryColor)
case _preferredWifiName = 133
static let preferredWifiName = Typed<String>(rawValue: ._preferredWifiName)
// MARK: - Endpoint
case _externalEndpointList = 135
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
// MARK: - URL
case _localEndpoint = 134
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
case _serverUrl = 10
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
// MARK: - Date
case _backupFailedSince = 5
static let backupFailedSince = Typed<Date>(rawValue: ._backupFailedSince)
// MARK: - Bool
case _backupRequireWifi = 6
static let backupRequireWifi = Typed<Bool>(rawValue: ._backupRequireWifi)
case _backupRequireCharging = 7
static let backupRequireCharging = Typed<Bool>(rawValue: ._backupRequireCharging)
case _autoBackup = 13
static let autoBackup = Typed<Bool>(rawValue: ._autoBackup)
case _backgroundBackup = 14
static let backgroundBackup = Typed<Bool>(rawValue: ._backgroundBackup)
case _loadPreview = 100
static let loadPreview = Typed<Bool>(rawValue: ._loadPreview)
case _loadOriginal = 101
static let loadOriginal = Typed<Bool>(rawValue: ._loadOriginal)
case _dynamicLayout = 104
static let dynamicLayout = Typed<Bool>(rawValue: ._dynamicLayout)
case _backgroundBackupTotalProgress = 107
static let backgroundBackupTotalProgress = Typed<Bool>(rawValue: ._backgroundBackupTotalProgress)
case _backgroundBackupSingleProgress = 108
static let backgroundBackupSingleProgress = Typed<Bool>(rawValue: ._backgroundBackupSingleProgress)
case _storageIndicator = 109
static let storageIndicator = Typed<Bool>(rawValue: ._storageIndicator)
case _advancedTroubleshooting = 114
static let advancedTroubleshooting = Typed<Bool>(rawValue: ._advancedTroubleshooting)
case _preferRemoteImage = 116
static let preferRemoteImage = Typed<Bool>(rawValue: ._preferRemoteImage)
case _loopVideo = 117
static let loopVideo = Typed<Bool>(rawValue: ._loopVideo)
case _mapShowFavoriteOnly = 118
static let mapShowFavoriteOnly = Typed<Bool>(rawValue: ._mapShowFavoriteOnly)
case _selfSignedCert = 120
static let selfSignedCert = Typed<Bool>(rawValue: ._selfSignedCert)
case _mapIncludeArchived = 121
static let mapIncludeArchived = Typed<Bool>(rawValue: ._mapIncludeArchived)
case _ignoreIcloudAssets = 122
static let ignoreIcloudAssets = Typed<Bool>(rawValue: ._ignoreIcloudAssets)
case _selectedAlbumSortReverse = 123
static let selectedAlbumSortReverse = Typed<Bool>(rawValue: ._selectedAlbumSortReverse)
case _mapwithPartners = 125
static let mapwithPartners = Typed<Bool>(rawValue: ._mapwithPartners)
case _enableHapticFeedback = 126
static let enableHapticFeedback = Typed<Bool>(rawValue: ._enableHapticFeedback)
case _dynamicTheme = 129
static let dynamicTheme = Typed<Bool>(rawValue: ._dynamicTheme)
case _colorfulInterface = 130
static let colorfulInterface = Typed<Bool>(rawValue: ._colorfulInterface)
case _syncAlbums = 131
static let syncAlbums = Typed<Bool>(rawValue: ._syncAlbums)
case _autoEndpointSwitching = 132
static let autoEndpointSwitching = Typed<Bool>(rawValue: ._autoEndpointSwitching)
case _loadOriginalVideo = 136
static let loadOriginalVideo = Typed<Bool>(rawValue: ._loadOriginalVideo)
case _manageLocalMediaAndroid = 137
static let manageLocalMediaAndroid = Typed<Bool>(rawValue: ._manageLocalMediaAndroid)
case _readonlyModeEnabled = 138
static let readonlyModeEnabled = Typed<Bool>(rawValue: ._readonlyModeEnabled)
case _autoPlayVideo = 139
static let autoPlayVideo = Typed<Bool>(rawValue: ._autoPlayVideo)
case _photoManagerCustomFilter = 1000
static let photoManagerCustomFilter = Typed<Bool>(rawValue: ._photoManagerCustomFilter)
case _betaPromptShown = 1001
static let betaPromptShown = Typed<Bool>(rawValue: ._betaPromptShown)
case _betaTimeline = 1002
static let betaTimeline = Typed<Bool>(rawValue: ._betaTimeline)
case _enableBackup = 1003
static let enableBackup = Typed<Bool>(rawValue: ._enableBackup)
case _useWifiForUploadVideos = 1004
static let useWifiForUploadVideos = Typed<Bool>(rawValue: ._useWifiForUploadVideos)
case _useWifiForUploadPhotos = 1005
static let useWifiForUploadPhotos = Typed<Bool>(rawValue: ._useWifiForUploadPhotos)
case _needBetaMigration = 1006
static let needBetaMigration = Typed<Bool>(rawValue: ._needBetaMigration)
case _shouldResetSync = 1007
static let shouldResetSync = Typed<Bool>(rawValue: ._shouldResetSync)
struct Typed<T>: RawRepresentable {
let rawValue: StoreKey
@_transparent
init(rawValue value: StoreKey) {
self.rawValue = value
}
}
}
enum BackupSelection: Int, QueryBindable {
case selected, none, excluded
}
enum AvatarColor: Int, QueryBindable {
case primary, pink, red, yellow, blue, green, purple, orange, gray, amber
}
enum AlbumUserRole: Int, QueryBindable {
case editor, viewer
}
enum MemoryType: Int, QueryBindable {
case onThisDay
}
-146
View File
@@ -1,146 +0,0 @@
import SQLiteData
enum StoreError: Error {
case invalidJSON(String)
case invalidURL(String)
case encodingFailed
}
protocol StoreConvertible {
associatedtype StorageType
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
static func toValue(_ value: Self) throws(StoreError) -> StorageType
}
extension Int: StoreConvertible {
static func fromValue(_ value: Int) -> Int { value }
static func toValue(_ value: Int) -> Int { value }
}
extension Bool: StoreConvertible {
static func fromValue(_ value: Int) -> Bool { value == 1 }
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
}
extension Date: StoreConvertible {
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
}
extension String: StoreConvertible {
static func fromValue(_ value: String) -> String { value }
static func toValue(_ value: String) -> String { value }
}
extension URL: StoreConvertible {
static func fromValue(_ value: String) throws(StoreError) -> URL {
guard let url = URL(string: value) else {
throw StoreError.invalidURL(value)
}
return url
}
static func toValue(_ value: URL) -> String { value.absoluteString }
}
extension StoreConvertible where Self: Codable, StorageType == String {
static var jsonDecoder: JSONDecoder { JSONDecoder() }
static var jsonEncoder: JSONEncoder { JSONEncoder() }
static func fromValue(_ value: String) throws(StoreError) -> Self {
do {
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
} catch {
throw StoreError.invalidJSON(value)
}
}
static func toValue(_ value: Self) throws(StoreError) -> String {
let encoded: Data
do {
encoded = try jsonEncoder.encode(value)
} catch {
throw StoreError.encodingFailed
}
guard let string = String(data: encoded, encoding: .utf8) else {
throw StoreError.encodingFailed
}
return string
}
}
extension Array: StoreConvertible where Element: Codable {
typealias StorageType = String
}
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
typealias StorageType = String
}
class StoreRepository {
private let db: DatabasePool
init(db: DatabasePool) {
self.db = db
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
let value = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
let value = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
let value = try T.toValue(value)
try await db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
let value = try T.toValue(value)
try await db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
}
}
}
-237
View File
@@ -1,237 +0,0 @@
import GRDB
import SQLiteData
@Table("asset_face_entity")
struct AssetFace {
let id: String
let assetId: String
let personId: String?
let imageWidth: Int
let imageHeight: Int
let boundingBoxX1: Int
let boundingBoxY1: Int
let boundingBoxX2: Int
let boundingBoxY2: Int
let sourceType: String
}
@Table("auth_user_entity")
struct AuthUser {
let id: String
let name: String
let email: String
let isAdmin: Bool
let hasProfileImage: Bool
let profileChangedAt: Date
let avatarColor: AvatarColor
let quotaSizeInBytes: Int
let quotaUsageInBytes: Int
let pinCode: String?
}
@Table("local_album_entity")
struct LocalAlbum {
let id: String
let backupSelection: BackupSelection
let linkedRemoteAlbumId: String?
let marker_: Bool?
let name: String
let isIosSharedAlbum: Bool
let updatedAt: Date
}
@Table("local_album_asset_entity")
struct LocalAlbumAsset {
let id: ID
let marker_: String?
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("local_asset_entity")
struct LocalAsset {
let id: String
let checksum: String?
let createdAt: Date
let durationInSeconds: Int?
let height: Int?
let isFavorite: Bool
let name: String
let orientation: String
let type: Int
let updatedAt: Date
let width: Int?
}
@Table("memory_asset_entity")
struct MemoryAsset {
let id: ID
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("memory_entity")
struct Memory {
let id: String
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
let ownerId: String
let type: MemoryType
let data: String
let isSaved: Bool
let memoryAt: Date
let seenAt: Date?
let showAt: Date?
let hideAt: Date?
}
@Table("partner_entity")
struct Partner {
let id: ID
let inTimeline: Bool
@Selection
struct ID {
let sharedById: String
let sharedWithId: String
}
}
@Table("person_entity")
struct Person {
let id: String
let createdAt: Date
let updatedAt: Date
let ownerId: String
let name: String
let faceAssetId: String?
let isFavorite: Bool
let isHidden: Bool
let color: String?
let birthDate: Date?
}
@Table("remote_album_entity")
struct RemoteAlbum {
let id: String
let createdAt: Date
let description: String?
let isActivityEnabled: Bool
let name: String
let order: Int
let ownerId: String
let thumbnailAssetId: String?
let updatedAt: Date
}
@Table("remote_album_asset_entity")
struct RemoteAlbumAsset {
let id: ID
@Selection
struct ID {
let assetId: String
let albumId: String
}
}
@Table("remote_album_user_entity")
struct RemoteAlbumUser {
let id: ID
let role: AlbumUserRole
@Selection
struct ID {
let albumId: String
let userId: String
}
}
@Table("remote_asset_entity")
struct RemoteAsset {
let id: String
let checksum: String?
let deletedAt: Date?
let isFavorite: Int
let libraryId: String?
let livePhotoVideoId: String?
let localDateTime: Date?
let orientation: String
let ownerId: String
let stackId: String?
let visibility: Int
}
@Table("remote_exif_entity")
struct RemoteExif {
@Column(primaryKey: true)
let assetId: String
let city: String?
let state: String?
let country: String?
let dateTimeOriginal: Date?
let description: String?
let height: Int?
let width: Int?
let exposureTime: String?
let fNumber: Double?
let fileSize: Int?
let focalLength: Double?
let latitude: Double?
let longitude: Double?
let iso: Int?
let make: String?
let model: String?
let lens: String?
let orientation: String?
let timeZone: String?
let rating: Int?
let projectionType: String?
}
@Table("stack_entity")
struct Stack {
let id: String
let createdAt: Date
let updatedAt: Date
let ownerId: String
let primaryAssetId: String
}
@Table("store_entity")
struct Store {
let id: StoreKey
let stringValue: String?
let intValue: Int?
}
@Table("user_entity")
struct User {
let id: String
let name: String
let email: String
let hasProfileImage: Bool
let profileChangedAt: Date
let avatarColor: AvatarColor
}
@Table("user_metadata_entity")
struct UserMetadata {
let id: ID
let value: Data
@Selection
struct ID {
let userId: String
let key: Date
}
}
@@ -72,6 +72,7 @@ enum StoreKey<T> {
autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
viewerQuickActionOrder<String>._(141),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
@@ -0,0 +1,85 @@
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class QuickActionService {
final ActionButtonOrderRepository _repository;
const QuickActionService(this._repository);
static final Set<ActionButtonType> _quickActionSet = Set<ActionButtonType>.unmodifiable(
ActionButtonBuilder.defaultQuickActionOrder,
);
List<ActionButtonType> get() {
return _repository.get();
}
Future<void> set(List<ActionButtonType> order) async {
final normalized = _normalizeQuickActionOrder(order);
await _repository.set(normalized);
}
Stream<List<ActionButtonType>> watch() {
return _repository.watch();
}
List<ActionButtonType> _normalizeQuickActionOrder(List<ActionButtonType> order) {
final ordered = <ActionButtonType>{};
for (final type in order) {
if (_quickActionSet.contains(type)) {
ordered.add(type);
}
}
ordered.addAll(ActionButtonBuilder.defaultQuickActionOrder);
return ordered.toList(growable: false);
}
List<ActionButtonType> buildQuickActionTypes(
ActionButtonContext context, {
List<ActionButtonType>? quickActionOrder,
int limit = ActionButtonBuilder.defaultQuickActionLimit,
}) {
final normalized = _normalizeQuickActionOrder(
quickActionOrder == null || quickActionOrder.isEmpty
? ActionButtonBuilder.defaultQuickActionOrder
: quickActionOrder,
);
final seen = <ActionButtonType>{};
final result = <ActionButtonType>[];
for (final type in normalized) {
if (!_quickActionSet.contains(type)) {
continue;
}
final resolved = _resolveQuickActionType(type, context);
if (!seen.add(resolved) || !resolved.shouldShow(context)) {
continue;
}
result.add(resolved);
if (result.length >= limit) {
break;
}
}
return result;
}
/// Resolve quick action type based on context (e.g., archive -> unarchive)
ActionButtonType _resolveQuickActionType(ActionButtonType type, ActionButtonContext context) {
if (type == ActionButtonType.archive && context.isArchived) {
return ActionButtonType.unarchive;
}
if (type == ActionButtonType.delete && context.asset.isLocalOnly) {
return ActionButtonType.deleteLocal;
}
return type;
}
}
@@ -0,0 +1,58 @@
import 'dart:convert';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class ActionButtonOrderRepository {
const ActionButtonOrderRepository();
static const storeKey = StoreKey.viewerQuickActionOrder;
List<ActionButtonType> get() {
final json = Store.tryGet(storeKey);
if (json == null || json.isEmpty) {
return ActionButtonBuilder.defaultQuickActionOrder;
}
final deserialized = _deserialize(json);
return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized;
}
Future<void> set(List<ActionButtonType> order) async {
final json = _serialize(order);
await Store.put(storeKey, json);
}
Stream<List<ActionButtonType>> watch() {
return Store.watch(storeKey).map((json) {
if (json == null || json.isEmpty) {
return ActionButtonBuilder.defaultQuickActionOrder;
}
final deserialized = _deserialize(json);
return deserialized.isEmpty ? ActionButtonBuilder.defaultQuickActionOrder : deserialized;
});
}
String _serialize(List<ActionButtonType> order) {
return jsonEncode(order.map((type) => type.name).toList());
}
List<ActionButtonType> _deserialize(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list
.whereType<String>()
.map((name) {
try {
return ActionButtonType.values.byName(name);
} catch (e) {
return null;
}
})
.whereType<ActionButtonType>()
.toList();
} catch (e) {
return [];
}
}
}
@@ -0,0 +1,173 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
final _features = [
_Feature(
name: 'Main Timeline',
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
onTap: (ctx, ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return Future.value();
}
final assets = await ref.read(remoteAssetRepositoryProvider).getSome(user.id);
final selectedAssets = await ctx.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: assets.toSet()),
);
Logger("FeaturesInDevelopment").fine("Selected ${selectedAssets?.length ?? 0} assets");
return Future.value();
},
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
),
_Feature(
name: 'Sync Local Full (1)',
icon: Icons.photo_library_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
),
_Feature(
name: 'Hash Local Assets (2)',
icon: Icons.numbers_outlined,
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
),
_Feature(
name: 'Sync Remote (3)',
icon: Icons.refresh_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
),
_Feature(
name: 'WAL Checkpoint',
icon: Icons.save_rounded,
onTap: (_, ref) => ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"),
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded,
onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(),
),
_Feature(
name: 'Clear Local Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_forever_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.localAssetEntity.deleteAll();
await db.localAlbumEntity.deleteAll();
await db.localAlbumAssetEntity.deleteAll();
},
),
_Feature(
name: 'Clear Remote Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.remoteAssetEntity.deleteAll();
await db.remoteExifEntity.deleteAll();
await db.remoteAlbumEntity.deleteAll();
await db.remoteAlbumUserEntity.deleteAll();
await db.remoteAlbumAssetEntity.deleteAll();
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
await db.assetFaceEntity.deleteAll();
},
),
_Feature(
name: 'Local Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
),
_Feature(
name: 'Remote Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
),
_Feature(
name: 'Reset Sqlite',
icon: Icons.table_view_rounded,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
onTap: (_, ref) async {
final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final migrator = drift.createMigrator();
for (final entity in drift.allSchemaEntities) {
await migrator.drop(entity);
await migrator.create(entity);
}
},
),
];
@RoutePage()
class FeatInDevPage extends StatelessWidget {
const FeatInDevPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
body: Column(
children: [
Flexible(
flex: 1,
child: ListView.builder(
itemBuilder: (_, index) {
final feat = _features[index];
return Consumer(
builder: (ctx, ref, _) => ListTile(
title: Text(feat.name, style: feat.style),
trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)),
),
);
},
itemCount: _features.length,
),
),
const Divider(height: 0),
],
),
);
}
}
class _Feature {
const _Feature({required this.name, required this.icon, required this.onTap, this.style});
final String name;
final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap;
}
@@ -1,51 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
final children = <Widget>[];
final items = [
(variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
}
return children;
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Immich UI Showcase')),
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),
),
);
}
}
@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
@@ -31,13 +30,11 @@ class DriftCropImagePage extends HookWidget {
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: const ImmichCloseButton(),
leading: CloseButton(color: context.primaryColor),
actions: [
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
@@ -75,17 +72,17 @@ class DriftCropImagePage extends HookWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateLeft(),
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateRight(),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
),
],
),
@@ -109,10 +109,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return;
}
final List<Widget> slivers = [
const CreateAlbumButton(),
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album)),
];
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
showModalBottomSheet(
context: context,
@@ -156,9 +153,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
}
if (!context.mounted) {
@@ -0,0 +1,35 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart';
class ReorderButtonsActionButton extends ConsumerWidget {
const ReorderButtonsActionButton({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'reorder_buttons'.tr(),
iconData: Icons.swap_vert,
iconColor: originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () async {
final viewerNotifier = ref.read(assetViewerProvider.notifier);
viewerNotifier.setBottomSheet(true);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (sheetContext) => const FractionallySizedBox(heightFactor: 0.75, child: QuickActionConfigurator()),
).whenComplete(() {
viewerNotifier.setBottomSheet(false);
});
},
);
}
}
@@ -12,10 +12,8 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
@@ -768,68 +766,3 @@ class AddToAlbumHeader extends ConsumerWidget {
);
}
}
class CreateAlbumButton extends ConsumerWidget {
const CreateAlbumButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
var albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
return;
}
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbum(title: albumName, assetIds: [asset.remoteId!]);
if (album == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
context.pop();
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}
@@ -1,53 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class NewAlbumNameModal extends StatefulWidget {
const NewAlbumNameModal({super.key});
@override
State<NewAlbumNameModal> createState() => _NewAlbumNameModalState();
}
class _NewAlbumNameModalState extends State<NewAlbumNameModal> {
TextEditingController nameController = TextEditingController();
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () {
context.pop(nameController.text.trim());
},
child: Text(
"create_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}
@@ -2,18 +2,19 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart';
import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
class ViewerBottomBar extends ConsumerWidget {
@@ -33,25 +34,53 @@ class ViewerBottomBar extends ConsumerWidget {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final quickActionOrder = ref.watch(viewerQuickActionOrderProvider);
if (!showControls) {
opacity = 0;
}
final originalTheme = context.themeData;
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnabled,
isStacked: asset is RemoteAsset && asset.stackId != null,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
final quickActionService = ref.watch(quickActionServiceProvider);
final quickActionTypes = quickActionService.buildQuickActionTypes(
buttonContext,
quickActionOrder: quickActionOrder,
);
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
];
Future<void> openConfigurator() async {
final viewerNotifier = ref.read(assetViewerProvider.notifier);
viewerNotifier.setBottomSheet(true);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (sheetContext) => const FractionallySizedBox(heightFactor: 0.75, child: QuickActionConfigurator()),
).whenComplete(() {
viewerNotifier.setBottomSheet(false);
});
}
final actions = quickActionTypes
.map((type) => type.buildButton(buttonContext))
.map((widget) => GestureDetector(onLongPress: openConfigurator, child: widget))
.toList(growable: false);
return IgnorePointer(
ignoring: opacity < 255,
@@ -0,0 +1,219 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/action_button_visuals.dart';
import 'package:immich_mobile/widgets/common/reorderable_drag_drop_grid.dart';
class QuickActionConfigurator extends ConsumerStatefulWidget {
const QuickActionConfigurator({super.key});
@override
ConsumerState<QuickActionConfigurator> createState() => _QuickActionConfiguratorState();
}
class _QuickActionConfiguratorState extends ConsumerState<QuickActionConfigurator> {
late List<ActionButtonType> _order;
late final ScrollController _scrollController;
bool _hasLocalChanges = false;
@override
void initState() {
super.initState();
_order = List<ActionButtonType>.from(ref.read(viewerQuickActionOrderProvider));
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onReorder(int oldIndex, int newIndex) {
setState(() {
final item = _order.removeAt(oldIndex);
_order.insert(newIndex, item);
_hasLocalChanges = true;
});
}
void _resetToDefault() {
setState(() {
_order = List<ActionButtonType>.from(ActionButtonBuilder.defaultQuickActionOrder);
_hasLocalChanges = true;
});
}
void _cancel() => Navigator.of(context).pop();
Future<void> _save() async {
await ref.read(viewerQuickActionOrderProvider.notifier).setOrder(_order);
_hasLocalChanges = false;
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const crossAxisCount = 4;
const crossAxisSpacing = 12.0;
const mainAxisSpacing = 12.0;
const tileHeight = 130.0;
final currentOrder = ref.watch(viewerQuickActionOrderProvider);
if (!_hasLocalChanges && !listEquals(_order, currentOrder)) {
_order = List<ActionButtonType>.from(currentOrder);
}
final hasChanges = !listEquals(currentOrder, _order);
return SafeArea(
child: Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20, top: 16),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
borderRadius: const BorderRadius.all(Radius.circular(2)),
),
),
const SizedBox(height: 16),
Text('quick_actions_settings_title'.tr(), style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'quick_actions_settings_description'.tr(
namedArgs: {'count': ActionButtonBuilder.defaultQuickActionLimit.toString()},
),
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final rows = (_order.length / crossAxisCount).ceil().clamp(1, 4);
final naturalHeight = rows * tileHeight + (rows - 1) * mainAxisSpacing;
final shouldScroll = naturalHeight > constraints.maxHeight;
final horizontalPadding = 8.0;
final tileWidth =
(constraints.maxWidth - horizontalPadding - (crossAxisSpacing * (crossAxisCount - 1))) /
crossAxisCount;
final childAspectRatio = tileWidth / tileHeight;
final gridController = shouldScroll ? _scrollController : null;
return ReorderableDragDropGrid(
scrollController: gridController,
itemCount: _order.length,
itemBuilder: (context, index) {
final type = _order[index];
return _QuickActionTile(index: index, type: type);
},
onReorder: _onReorder,
crossAxisCount: crossAxisCount,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
childAspectRatio: childAspectRatio,
shouldScroll: shouldScroll,
);
},
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(onPressed: _resetToDefault, child: const Text('reset').tr()),
Row(
children: [
TextButton(onPressed: _cancel, child: const Text('cancel').tr()),
const SizedBox(width: 8),
FilledButton(onPressed: hasChanges ? _save : null, child: const Text('done').tr()),
],
),
],
),
],
),
),
);
}
}
class _QuickActionTile extends StatelessWidget {
final int index;
final ActionButtonType type;
const _QuickActionTile({required this.index, required this.type});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final borderColor = theme.dividerColor;
final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2);
final indicatorColor = theme.colorScheme.primary;
final accentColor = theme.colorScheme.onSurface.withValues(alpha: 0.7);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border.all(color: borderColor),
color: backgroundColor,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: indicatorColor.withValues(alpha: 0.15),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Center(
child: Text(
'${index + 1}',
style: theme.textTheme.labelSmall?.copyWith(color: indicatorColor, fontWeight: FontWeight.bold),
),
),
),
const Spacer(),
Icon(Icons.drag_indicator_rounded, size: 18, color: indicatorColor),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.topCenter,
child: Icon(type.iconData, size: 28, color: theme.colorScheme.onSurface),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.topCenter,
child: Text(
type.localizedLabel(context),
style: theme.textTheme.labelSmall?.copyWith(color: accentColor),
textAlign: TextAlign.center,
maxLines: 3,
),
),
],
),
),
);
}
}
@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
@@ -105,12 +104,7 @@ class NativeVideoViewer extends HookConsumerWidget {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
final source = await VideoSource.init(path: file.path, type: VideoSourceType.file);
return source;
}
@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/quick_action.service.dart';
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
final actionButtonOrderRepositoryProvider = Provider<ActionButtonOrderRepository>(
(ref) => const ActionButtonOrderRepository(),
);
final quickActionServiceProvider = Provider<QuickActionService>(
(ref) => QuickActionService(ref.watch(actionButtonOrderRepositoryProvider)),
);
final viewerQuickActionOrderProvider = StateNotifierProvider<ViewerQuickActionOrderNotifier, List<ActionButtonType>>(
(ref) => ViewerQuickActionOrderNotifier(ref.watch(quickActionServiceProvider)),
);
class ViewerQuickActionOrderNotifier extends StateNotifier<List<ActionButtonType>> {
final QuickActionService _service;
StreamSubscription<List<ActionButtonType>>? _subscription;
ViewerQuickActionOrderNotifier(this._service) : super(_service.get()) {
_subscription = _service.watch().listen((order) {
state = order;
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
Future<void> setOrder(List<ActionButtonType> order) async {
if (listEquals(state, order)) {
return;
}
final previous = state;
state = order;
try {
await _service.set(order);
} catch (error) {
state = previous;
rethrow;
}
}
}
@@ -17,10 +17,8 @@ class PersonApiRepository extends ApiRepository {
}
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response);
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday)));
return _toPerson(dto);
}
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
+2 -2
View File
@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
@@ -286,6 +286,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: FeatInDevRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]),
@@ -337,7 +338,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+16 -16
View File
@@ -1648,6 +1648,22 @@ class FavoritesRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [FeatInDevPage]
class FeatInDevRoute extends PageRouteInfo<void> {
const FeatInDevRoute({List<PageRouteInfo>? children})
: super(FeatInDevRoute.name, initialChildren: children);
static const String name = 'FeatInDevRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const FeatInDevPage();
},
);
}
/// generated route for
/// [FilterImagePage]
class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
@@ -1815,22 +1831,6 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [ImmichUIShowcasePage]
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
static const String name = 'ImmichUIShowcaseRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const ImmichUIShowcasePage();
},
);
}
/// generated route for
/// [LibraryPage]
class LibraryRoute extends PageRouteInfo<void> {
@@ -65,6 +65,7 @@ enum AppSettingsEnum<T> {
class AppSettingsService {
const AppSettingsService();
T getSetting<T>(AppSettingsEnum<T> setting) {
return Store.get(setting.storeKey, setting.defaultValue);
}
+30
View File
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -17,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
@@ -28,6 +30,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/reorder_buttons_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
@@ -57,6 +60,8 @@ class ActionButtonContext {
enum ActionButtonType {
advancedInfo,
share,
edit,
add,
shareLink,
similarPhotos,
archive,
@@ -73,10 +78,16 @@ enum ActionButtonType {
unstack,
likeActivity;
String toJson() => name;
bool shouldShow(ActionButtonContext context) {
return switch (this) {
ActionButtonType.advancedInfo => context.advancedTroubleshooting,
ActionButtonType.share => true,
ActionButtonType.edit =>
!context.isInLockedView && //
context.asset.isImage,
ActionButtonType.add => context.asset.hasRemote,
ActionButtonType.shareLink =>
!context.isInLockedView && //
context.asset.hasRemote,
@@ -145,6 +156,8 @@ enum ActionButtonType {
return switch (this) {
ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source),
ActionButtonType.share => ShareActionButton(source: context.source),
ActionButtonType.edit => const EditImageActionButton(),
ActionButtonType.add => const AddActionButton(),
ActionButtonType.shareLink => ShareLinkActionButton(source: context.source),
ActionButtonType.archive => ArchiveActionButton(source: context.source),
ActionButtonType.unarchive => UnArchiveActionButton(source: context.source),
@@ -170,6 +183,19 @@ enum ActionButtonType {
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static const int defaultQuickActionLimit = 4;
static const List<ActionButtonType> defaultQuickActionOrder = [
ActionButtonType.share,
ActionButtonType.upload,
ActionButtonType.edit,
ActionButtonType.add,
ActionButtonType.archive,
ActionButtonType.delete,
ActionButtonType.removeFromAlbum,
ActionButtonType.likeActivity,
];
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
@@ -194,6 +220,7 @@ class ViewerKebabMenuButtonContext {
enum ViewerKebabMenuButtonType {
openInfo,
viewInTimeline,
reorderButtons,
cast,
download;
@@ -203,6 +230,7 @@ enum ViewerKebabMenuButtonType {
int get group => switch (this) {
ViewerKebabMenuButtonType.openInfo => 0,
ViewerKebabMenuButtonType.viewInTimeline => 1,
ViewerKebabMenuButtonType.reorderButtons => 1,
ViewerKebabMenuButtonType.cast => 1,
ViewerKebabMenuButtonType.download => 1,
};
@@ -219,6 +247,7 @@ enum ViewerKebabMenuButtonType {
context.isOwner,
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
ViewerKebabMenuButtonType.reorderButtons => true,
};
}
@@ -245,6 +274,7 @@ enum ViewerKebabMenuButtonType {
),
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
ViewerKebabMenuButtonType.reorderButtons => ReorderButtonsActionButton(originalTheme: context.originalTheme),
};
}
}
@@ -0,0 +1,55 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
extension ActionButtonTypeVisuals on ActionButtonType {
IconData get iconData {
return switch (this) {
ActionButtonType.advancedInfo => Icons.help_outline_rounded,
ActionButtonType.share => Icons.share_rounded,
ActionButtonType.edit => Icons.tune,
ActionButtonType.add => Icons.add,
ActionButtonType.shareLink => Icons.link_rounded,
ActionButtonType.similarPhotos => Icons.compare,
ActionButtonType.archive => Icons.archive_outlined,
ActionButtonType.unarchive => Icons.unarchive_outlined,
ActionButtonType.download => Icons.download,
ActionButtonType.trash => Icons.delete_outline_rounded,
ActionButtonType.deletePermanent => Icons.delete_forever,
ActionButtonType.delete => Icons.delete_sweep_outlined,
ActionButtonType.moveToLockFolder => Icons.lock_outline_rounded,
ActionButtonType.removeFromLockFolder => Icons.lock_open_rounded,
ActionButtonType.deleteLocal => Icons.no_cell_outlined,
ActionButtonType.upload => Icons.backup_outlined,
ActionButtonType.removeFromAlbum => Icons.remove_circle_outline,
ActionButtonType.unstack => Icons.layers_clear_outlined,
ActionButtonType.likeActivity => Icons.favorite_border,
};
}
String get _labelKey {
return switch (this) {
ActionButtonType.advancedInfo => 'troubleshoot',
ActionButtonType.share => 'share',
ActionButtonType.edit => 'edit',
ActionButtonType.add => 'add_to_bottom_bar',
ActionButtonType.shareLink => 'share_link',
ActionButtonType.similarPhotos => 'view_similar_photos',
ActionButtonType.archive => 'to_archive',
ActionButtonType.unarchive => 'unarchive',
ActionButtonType.download => 'download',
ActionButtonType.trash => 'control_bottom_app_bar_trash_from_immich',
ActionButtonType.deletePermanent => 'delete_permanently',
ActionButtonType.delete => 'delete',
ActionButtonType.moveToLockFolder => 'move_to_locked_folder',
ActionButtonType.removeFromLockFolder => 'remove_from_locked_folder',
ActionButtonType.deleteLocal => 'control_bottom_app_bar_delete_from_local',
ActionButtonType.upload => 'upload',
ActionButtonType.removeFromAlbum => 'remove_from_album',
ActionButtonType.unstack => 'unstack',
ActionButtonType.likeActivity => 'like',
};
}
String localizedLabel(BuildContext _) => _labelKey.tr();
}
@@ -53,18 +53,16 @@ class ServerUpdateNotification extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
serverInfoState.versionStatus.message,
textAlign: TextAlign.start,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
),
Text(
serverInfoState.versionStatus.message,
textAlign: TextAlign.start,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
),
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate ||
serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[
const SizedBox(width: 8),
const Spacer(),
TextButton(
onPressed: openUpdateLink,
style: TextButton.styleFrom(
@@ -155,8 +155,8 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (kDebugMode || kProfileMode)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
),
if (isCasting)
Padding(
@@ -74,8 +74,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
),
if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
/// A callback that is called when items are reordered.
/// [oldIndex] is the original index of the item being moved.
/// [newIndex] is the target index where the item should be moved to.
typedef ReorderCallback = void Function(int oldIndex, int newIndex);
/// A callback that is called during drag to update hover state.
/// [draggedIndex] is the index of the item being dragged.
/// [targetIndex] is the index of the item being hovered over.
typedef DragUpdateCallback = void Function(int draggedIndex, int targetIndex);
/// A reorderable grid that supports drag and drop reordering with smooth animations.
///
/// This widget provides a drag-and-drop interface for reordering items in a grid layout.
/// Items can be dragged to new positions, and the grid will animate smoothly to reflect
/// the new order.
///
/// Features:
/// - Smooth animations during drag and drop
/// - Instant snap animation on drop completion
/// - Visual feedback during dragging
/// - Customizable grid layout parameters
class ReorderableDragDropGrid extends StatefulWidget {
/// Controller for scrolling the grid.
final ScrollController? scrollController;
/// The number of items to display.
final int itemCount;
/// Builder function to create each grid item.
final Widget Function(BuildContext context, int index) itemBuilder;
/// Callback when items are reordered.
final ReorderCallback onReorder;
/// Number of columns in the grid.
final int crossAxisCount;
/// Horizontal spacing between grid items.
final double crossAxisSpacing;
/// Vertical spacing between grid items.
final double mainAxisSpacing;
/// The ratio of width to height for each grid item.
final double childAspectRatio;
/// Whether the grid should be scrollable.
final bool shouldScroll;
/// Scale factor for the dragged item feedback widget.
final double feedbackScaleFactor;
/// Opacity for the dragged item feedback widget.
final double feedbackOpacity;
const ReorderableDragDropGrid({
super.key,
this.scrollController,
required this.itemCount,
required this.itemBuilder,
required this.onReorder,
required this.crossAxisCount,
required this.crossAxisSpacing,
required this.mainAxisSpacing,
required this.childAspectRatio,
this.shouldScroll = true,
this.feedbackScaleFactor = 1.05,
this.feedbackOpacity = 0.9,
});
@override
State<ReorderableDragDropGrid> createState() => _ReorderableDragDropGridState();
}
class _ReorderableDragDropGridState extends State<ReorderableDragDropGrid> {
int? _draggingIndex;
late List<int> _itemOrder;
int? _lastHoveredIndex;
bool _snapNow = false;
@override
void initState() {
super.initState();
_itemOrder = List.generate(widget.itemCount, (index) => index);
}
@override
void didUpdateWidget(ReorderableDragDropGrid oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemCount != widget.itemCount) {
_itemOrder = List.generate(widget.itemCount, (index) => index);
}
}
void _updateHover(int draggedIndex, int targetIndex) {
if (draggedIndex == targetIndex || _draggingIndex == null) return;
setState(() {
_lastHoveredIndex = targetIndex;
final newOrder = List<int>.from(_itemOrder);
final draggedOrderIndex = newOrder.indexOf(draggedIndex);
final targetOrderIndex = newOrder.indexOf(targetIndex);
newOrder.removeAt(draggedOrderIndex);
newOrder.insert(targetOrderIndex, draggedIndex);
_itemOrder = newOrder;
});
}
void _handleDragEnd(int draggedIndex, int? targetIndex) {
final effectiveTargetIndex =
targetIndex ??
(() {
final currentVisualIndex = _itemOrder.indexOf(draggedIndex);
if (currentVisualIndex != draggedIndex) {
return _itemOrder[currentVisualIndex];
}
return null;
})();
if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) {
widget.onReorder(draggedIndex, effectiveTargetIndex);
}
_armSnapNow();
setState(() {
_draggingIndex = null;
_lastHoveredIndex = null;
_itemOrder = List.generate(widget.itemCount, (i) => i);
});
}
void _armSnapNow() {
setState(() => _snapNow = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _snapNow = false);
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final tileWidth =
(constraints.maxWidth - (widget.crossAxisSpacing * (widget.crossAxisCount - 1))) / widget.crossAxisCount;
final tileHeight = tileWidth / widget.childAspectRatio;
final rows = (_itemOrder.length / widget.crossAxisCount).ceil();
final totalHeight = rows * tileHeight + (rows - 1) * widget.mainAxisSpacing;
return SingleChildScrollView(
controller: widget.scrollController,
physics: widget.shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(),
child: SizedBox(
width: constraints.maxWidth,
height: totalHeight,
child: Stack(
children: List.generate(widget.itemCount, (index) {
final visualIndex = _itemOrder.indexOf(index);
final isDragging = _draggingIndex == index;
final row = visualIndex ~/ widget.crossAxisCount;
final col = visualIndex % widget.crossAxisCount;
final left = col * (tileWidth + widget.crossAxisSpacing);
final top = row * (tileHeight + widget.mainAxisSpacing);
return _AnimatedGridItem(
key: ValueKey(index),
index: index,
isDragging: isDragging,
snapNow: _snapNow,
tileWidth: tileWidth,
tileHeight: tileHeight,
left: left,
top: top,
feedbackScaleFactor: widget.feedbackScaleFactor,
feedbackOpacity: widget.feedbackOpacity,
onDragStarted: () {
setState(() {
_draggingIndex = index;
_lastHoveredIndex = index;
});
},
onDragUpdate: (draggedIndex, targetIndex) {
_updateHover(draggedIndex, targetIndex);
},
onDragCompleted: (draggedIndex) {
_handleDragEnd(draggedIndex, _lastHoveredIndex);
},
child: widget.itemBuilder(context, index),
);
}),
),
),
);
},
);
}
}
class _AnimatedGridItem extends StatelessWidget {
final int index;
final bool isDragging;
final bool snapNow;
final double tileWidth;
final double tileHeight;
final double left;
final double top;
final double feedbackScaleFactor;
final double feedbackOpacity;
final VoidCallback onDragStarted;
final DragUpdateCallback onDragUpdate;
final Function(int draggedIndex) onDragCompleted;
final Widget child;
const _AnimatedGridItem({
super.key,
required this.index,
required this.isDragging,
required this.snapNow,
required this.tileWidth,
required this.tileHeight,
required this.left,
required this.top,
required this.feedbackScaleFactor,
required this.feedbackOpacity,
required this.onDragStarted,
required this.onDragUpdate,
required this.onDragCompleted,
required this.child,
});
@override
Widget build(BuildContext context) {
final Duration animDuration = snapNow ? Duration.zero : const Duration(milliseconds: 150);
return AnimatedPositioned(
duration: animDuration,
curve: Curves.easeInOut,
left: left,
top: top,
width: tileWidth,
height: tileHeight,
child: DragTarget<int>(
onWillAcceptWithDetails: (details) {
if (details.data != index) {
onDragUpdate(details.data, index);
}
return details.data != index;
},
builder: (context, candidateData, rejectedData) {
Widget displayChild = child;
if (isDragging) {
displayChild = Opacity(opacity: 0.0, child: child);
}
return Draggable<int>(
data: index,
feedback: Material(
color: Colors.transparent,
child: SizedBox(
width: tileWidth,
height: tileHeight,
child: Opacity(
opacity: feedbackOpacity,
child: Transform.scale(scale: feedbackScaleFactor, child: child),
),
),
),
childWhenDragging: const SizedBox.shrink(),
onDragStarted: onDragStarted,
onDragCompleted: () {
onDragCompleted(index);
},
onDraggableCanceled: (_, __) {
onDragCompleted(index);
},
child: displayChild,
);
},
),
);
}
}
-7
View File
@@ -163,12 +163,8 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**createFavoriteLocation**](doc//MapApi.md#createfavoritelocation) | **POST** /map/favorite-locations | Create favorite location
*MapApi* | [**deleteFavoriteLocation**](doc//MapApi.md#deletefavoritelocation) | **DELETE** /map/favorite-locations/{id} | Delete favorite location
*MapApi* | [**getFavoriteLocations**](doc//MapApi.md#getfavoritelocations) | **GET** /map/favorite-locations | Get favorite locations
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
*MapApi* | [**updateFavoriteLocation**](doc//MapApi.md#updatefavoritelocation) | **PUT** /map/favorite-locations/{id} | Update favorite location
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | Create a memory
*MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | Delete a memory
@@ -388,7 +384,6 @@ Class | Method | HTTP request | Description
- [Colorspace](doc//Colorspace.md)
- [ContributorCountResponseDto](doc//ContributorCountResponseDto.md)
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateFavoriteLocationDto](doc//CreateFavoriteLocationDto.md)
- [CreateLibraryDto](doc//CreateLibraryDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
@@ -404,7 +399,6 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- [FavoriteLocationResponseDto](doc//FavoriteLocationResponseDto.md)
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
@@ -619,7 +613,6 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateFavoriteLocationDto](doc//UpdateFavoriteLocationDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)
-3
View File
@@ -136,7 +136,6 @@ part 'model/check_existing_assets_response_dto.dart';
part 'model/colorspace.dart';
part 'model/contributor_count_response_dto.dart';
part 'model/create_album_dto.dart';
part 'model/create_favorite_location_dto.dart';
part 'model/create_library_dto.dart';
part 'model/create_profile_image_response_dto.dart';
part 'model/database_backup_config.dart';
@@ -152,7 +151,6 @@ part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart';
part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart';
part 'model/favorite_location_response_dto.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
@@ -367,7 +365,6 @@ part 'model/trash_response_dto.dart';
part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_favorite_location_dto.dart';
part 'model/update_library_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart';
-217
View File
@@ -16,162 +16,6 @@ class MapApi {
final ApiClient apiClient;
/// Create favorite location
///
/// Create a new favorite location for the user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [CreateFavoriteLocationDto] createFavoriteLocationDto (required):
Future<Response> createFavoriteLocationWithHttpInfo(CreateFavoriteLocationDto createFavoriteLocationDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations';
// ignore: prefer_final_locals
Object? postBody = createFavoriteLocationDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Create favorite location
///
/// Create a new favorite location for the user.
///
/// Parameters:
///
/// * [CreateFavoriteLocationDto] createFavoriteLocationDto (required):
Future<FavoriteLocationResponseDto?> createFavoriteLocation(CreateFavoriteLocationDto createFavoriteLocationDto,) async {
final response = await createFavoriteLocationWithHttpInfo(createFavoriteLocationDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'FavoriteLocationResponseDto',) as FavoriteLocationResponseDto;
}
return null;
}
/// Delete favorite location
///
/// Delete a favorite location by its ID.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteFavoriteLocationWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Delete favorite location
///
/// Delete a favorite location by its ID.
///
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteFavoriteLocation(String id,) async {
final response = await deleteFavoriteLocationWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Get favorite locations
///
/// Retrieve a list of user's favorite locations.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getFavoriteLocationsWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Get favorite locations
///
/// Retrieve a list of user's favorite locations.
Future<List<FavoriteLocationResponseDto>?> getFavoriteLocations() async {
final response = await getFavoriteLocationsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<FavoriteLocationResponseDto>') as List)
.cast<FavoriteLocationResponseDto>()
.toList(growable: false);
}
return null;
}
/// Retrieve map markers
///
/// Retrieve a list of latitude and longitude coordinates for every asset with location data.
@@ -335,65 +179,4 @@ class MapApi {
}
return null;
}
/// Update favorite location
///
/// Update an existing favorite location.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateFavoriteLocationDto] updateFavoriteLocationDto (required):
Future<Response> updateFavoriteLocationWithHttpInfo(String id, UpdateFavoriteLocationDto updateFavoriteLocationDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateFavoriteLocationDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Update favorite location
///
/// Update an existing favorite location.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateFavoriteLocationDto] updateFavoriteLocationDto (required):
Future<FavoriteLocationResponseDto?> updateFavoriteLocation(String id, UpdateFavoriteLocationDto updateFavoriteLocationDto,) async {
final response = await updateFavoriteLocationWithHttpInfo(id, updateFavoriteLocationDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'FavoriteLocationResponseDto',) as FavoriteLocationResponseDto;
}
return null;
}
}
-6
View File
@@ -320,8 +320,6 @@ class ApiClient {
return ContributorCountResponseDto.fromJson(value);
case 'CreateAlbumDto':
return CreateAlbumDto.fromJson(value);
case 'CreateFavoriteLocationDto':
return CreateFavoriteLocationDto.fromJson(value);
case 'CreateLibraryDto':
return CreateLibraryDto.fromJson(value);
case 'CreateProfileImageResponseDto':
@@ -352,8 +350,6 @@ class ApiClient {
return FaceDto.fromJson(value);
case 'FacialRecognitionConfig':
return FacialRecognitionConfig.fromJson(value);
case 'FavoriteLocationResponseDto':
return FavoriteLocationResponseDto.fromJson(value);
case 'FoldersResponse':
return FoldersResponse.fromJson(value);
case 'FoldersUpdate':
@@ -782,8 +778,6 @@ class ApiClient {
return UpdateAlbumUserDto.fromJson(value);
case 'UpdateAssetDto':
return UpdateAssetDto.fromJson(value);
case 'UpdateFavoriteLocationDto':
return UpdateFavoriteLocationDto.fromJson(value);
case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value);
case 'UsageByUserDto':
-115
View File
@@ -1,115 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CreateFavoriteLocationDto {
/// Returns a new [CreateFavoriteLocationDto] instance.
CreateFavoriteLocationDto({
required this.latitude,
required this.longitude,
required this.name,
});
num latitude;
num longitude;
String name;
@override
bool operator ==(Object other) => identical(this, other) || other is CreateFavoriteLocationDto &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(latitude.hashCode) +
(longitude.hashCode) +
(name.hashCode);
@override
String toString() => 'CreateFavoriteLocationDto[latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'latitude'] = this.latitude;
json[r'longitude'] = this.longitude;
json[r'name'] = this.name;
return json;
}
/// Returns a new [CreateFavoriteLocationDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CreateFavoriteLocationDto? fromJson(dynamic value) {
upgradeDto(value, "CreateFavoriteLocationDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return CreateFavoriteLocationDto(
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<CreateFavoriteLocationDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <CreateFavoriteLocationDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CreateFavoriteLocationDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CreateFavoriteLocationDto> mapFromJson(dynamic json) {
final map = <String, CreateFavoriteLocationDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CreateFavoriteLocationDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CreateFavoriteLocationDto-objects as value to a dart map
static Map<String, List<CreateFavoriteLocationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CreateFavoriteLocationDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = CreateFavoriteLocationDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'latitude',
'longitude',
'name',
};
}
@@ -1,135 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class FavoriteLocationResponseDto {
/// Returns a new [FavoriteLocationResponseDto] instance.
FavoriteLocationResponseDto({
required this.id,
required this.latitude,
required this.longitude,
required this.name,
});
String id;
num? latitude;
num? longitude;
String name;
@override
bool operator ==(Object other) => identical(this, other) || other is FavoriteLocationResponseDto &&
other.id == id &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) +
(name.hashCode);
@override
String toString() => 'FavoriteLocationResponseDto[id=$id, latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
if (this.latitude != null) {
json[r'latitude'] = this.latitude;
} else {
// json[r'latitude'] = null;
}
if (this.longitude != null) {
json[r'longitude'] = this.longitude;
} else {
// json[r'longitude'] = null;
}
json[r'name'] = this.name;
return json;
}
/// Returns a new [FavoriteLocationResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static FavoriteLocationResponseDto? fromJson(dynamic value) {
upgradeDto(value, "FavoriteLocationResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return FavoriteLocationResponseDto(
id: mapValueOfType<String>(json, r'id')!,
latitude: json[r'latitude'] == null
? null
: num.parse('${json[r'latitude']}'),
longitude: json[r'longitude'] == null
? null
: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<FavoriteLocationResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <FavoriteLocationResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = FavoriteLocationResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, FavoriteLocationResponseDto> mapFromJson(dynamic json) {
final map = <String, FavoriteLocationResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = FavoriteLocationResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of FavoriteLocationResponseDto-objects as value to a dart map
static Map<String, List<FavoriteLocationResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<FavoriteLocationResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = FavoriteLocationResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'id',
'latitude',
'longitude',
'name',
};
}
-142
View File
@@ -1,142 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UpdateFavoriteLocationDto {
/// Returns a new [UpdateFavoriteLocationDto] instance.
UpdateFavoriteLocationDto({
this.latitude,
this.longitude,
this.name,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? latitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? longitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? name;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateFavoriteLocationDto &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'UpdateFavoriteLocationDto[latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.latitude != null) {
json[r'latitude'] = this.latitude;
} else {
// json[r'latitude'] = null;
}
if (this.longitude != null) {
json[r'longitude'] = this.longitude;
} else {
// json[r'longitude'] = null;
}
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
return json;
}
/// Returns a new [UpdateFavoriteLocationDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateFavoriteLocationDto? fromJson(dynamic value) {
upgradeDto(value, "UpdateFavoriteLocationDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateFavoriteLocationDto(
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name'),
);
}
return null;
}
static List<UpdateFavoriteLocationDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateFavoriteLocationDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateFavoriteLocationDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateFavoriteLocationDto> mapFromJson(dynamic json) {
final map = <String, UpdateFavoriteLocationDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateFavoriteLocationDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateFavoriteLocationDto-objects as value to a dart map
static Map<String, List<UpdateFavoriteLocationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateFavoriteLocationDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateFavoriteLocationDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
-3
View File
@@ -1,3 +0,0 @@
export 'src/buttons/close_button.dart';
export 'src/buttons/icon_button.dart';
export 'src/types.dart';
@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/buttons/icon_button.dart';
import 'package:immich_ui/src/types.dart';
class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichCloseButton({
super.key,
this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.ghost,
});
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onTap: onTap ?? () => Navigator.of(context).pop(),
);
}
@@ -1,48 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichIconButton({
super.key,
required this.icon,
required this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
});
@override
Widget build(BuildContext context) {
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
},
};
return IconButton(
icon: Icon(icon),
onPressed: onTap,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
);
}
}
-9
View File
@@ -1,9 +0,0 @@
enum ImmichVariant {
filled,
ghost,
}
enum ImmichColor {
primary,
secondary,
}
-55
View File
@@ -1,55 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sdks:
dart: ">=3.8.0-0 <4.0.0"
-12
View File
@@ -1,12 +0,0 @@
name: immich_ui
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter:
uses-material-design: true
-7
View File
@@ -1015,13 +1015,6 @@ packages:
relative: true
source: path
version: "0.0.0"
immich_ui:
dependency: "direct main"
description:
path: "packages/ui"
relative: true
source: path
version: "0.0.0"
integration_test:
dependency: "direct dev"
description: flutter
-2
View File
@@ -43,8 +43,6 @@ dependencies:
hooks_riverpod: ^2.6.1
http: ^1.5.0
image_picker: ^1.2.0
immich_ui:
path: './packages/ui'
intl: ^0.20.2
isar:
git:
@@ -0,0 +1,150 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/quick_action.service.dart';
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
void main() {
group('QuickActionService', () {
late QuickActionService service;
setUp(() {
// Use repository with default behavior for testing
service = const QuickActionService(ActionButtonOrderRepository());
});
test('buildQuickActionTypes should respect custom order', () {
final remoteAsset = RemoteAsset(
id: 'test-id',
name: 'test.jpg',
checksum: 'checksum',
type: AssetType.image,
ownerId: 'owner-id',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
final customOrder = [
ActionButtonType.archive,
ActionButtonType.share,
ActionButtonType.edit,
ActionButtonType.delete,
];
final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder);
expect(types.length, lessThanOrEqualTo(ActionButtonBuilder.defaultQuickActionLimit));
expect(types.first, ActionButtonType.archive);
expect(types[1], ActionButtonType.share);
});
test('buildQuickActionTypes should resolve archive to unarchive when archived', () {
final remoteAsset = RemoteAsset(
id: 'test-id',
name: 'test.jpg',
checksum: 'checksum',
type: AssetType.image,
ownerId: 'owner-id',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: true, // archived
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
final customOrder = [ActionButtonType.archive];
final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder);
expect(types.contains(ActionButtonType.unarchive), isTrue);
expect(types.contains(ActionButtonType.archive), isFalse);
});
test('buildQuickActionTypes should filter types that shouldShow returns false', () {
final localAsset = LocalAsset(
id: 'local-id',
name: 'test.jpg',
checksum: 'checksum',
type: AssetType.image,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
final customOrder = [
ActionButtonType.archive, // should not show for local-only asset
ActionButtonType.share,
];
final types = service.buildQuickActionTypes(context, quickActionOrder: customOrder);
expect(types.contains(ActionButtonType.archive), isFalse);
expect(types.contains(ActionButtonType.share), isTrue);
});
test('buildQuickActionTypes should respect limit', () {
final remoteAsset = RemoteAsset(
id: 'test-id',
name: 'test.jpg',
checksum: 'checksum',
type: AssetType.image,
ownerId: 'owner-id',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
final types = service.buildQuickActionTypes(
context,
quickActionOrder: ActionButtonBuilder.defaultQuickActionOrder,
limit: 2,
);
expect(types.length, 2);
});
});
}
@@ -3,6 +3,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/quick_action.service.dart';
import 'package:immich_mobile/infrastructure/repositories/action_button_order.repository.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({
@@ -137,6 +142,56 @@ void main() {
});
});
group('edit button', () {
test('should show for images when not in locked view', () {
final context = ActionButtonContext(
asset: createRemoteAsset(type: AssetType.image),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.edit.shouldShow(context), isTrue);
});
test('should not show in locked view', () {
final context = ActionButtonContext(
asset: createRemoteAsset(type: AssetType.image),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.edit.shouldShow(context), isFalse);
});
test('should not show for non-image assets', () {
final context = ActionButtonContext(
asset: createRemoteAsset(type: AssetType.video),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.edit.shouldShow(context), isFalse);
});
});
group('shareLink button', () {
test('should show when not in locked view and asset has remote', () {
final remoteAsset = createRemoteAsset();
@@ -961,5 +1016,38 @@ void main() {
expect(archivedWidgets, isNotEmpty);
expect(nonArchivedWidgets, isNotEmpty);
});
test('should build quick actions honoring custom order', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
final quickActionService = const QuickActionService(ActionButtonOrderRepository());
final quickActionTypes = quickActionService.buildQuickActionTypes(
context,
quickActionOrder: const [
ActionButtonType.archive,
ActionButtonType.share,
ActionButtonType.edit,
ActionButtonType.delete,
],
);
final quickActions = quickActionTypes.map((type) => type.buildButton(context)).toList();
expect(quickActions.length, ActionButtonBuilder.defaultQuickActionLimit);
expect(quickActions.first, isA<ArchiveActionButton>());
expect(quickActions[1], isA<ShareActionButton>());
expect(quickActions[2], isA<EditImageActionButton>());
});
});
}
-268
View File
@@ -5592,216 +5592,6 @@
"x-immich-state": "Stable"
}
},
"/map/favorite-locations": {
"get": {
"description": "Retrieve a list of user's favorite locations.",
"operationId": "getFavoriteLocations",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/FavoriteLocationResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get favorite locations",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"post": {
"description": "Create a new favorite location for the user.",
"operationId": "createFavoriteLocation",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateFavoriteLocationDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FavoriteLocationResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Create favorite location",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
}
},
"/map/favorite-locations/{id}": {
"delete": {
"description": "Delete a favorite location by its ID.",
"operationId": "deleteFavoriteLocation",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete favorite location",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"put": {
"description": "Update an existing favorite location.",
"operationId": "updateFavoriteLocation",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateFavoriteLocationDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FavoriteLocationResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update favorite location",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
}
},
"/map/markers": {
"get": {
"description": "Retrieve a list of latitude and longitude coordinates for every asset with location data.",
@@ -16360,25 +16150,6 @@
],
"type": "object"
},
"CreateFavoriteLocationDto": {
"properties": {
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"name": {
"type": "string"
}
},
"required": [
"latitude",
"longitude",
"name"
],
"type": "object"
},
"CreateLibraryDto": {
"properties": {
"exclusionPatterns": {
@@ -16783,31 +16554,6 @@
],
"type": "object"
},
"FavoriteLocationResponseDto": {
"properties": {
"id": {
"type": "string"
},
"latitude": {
"nullable": true,
"type": "number"
},
"longitude": {
"nullable": true,
"type": "number"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"latitude",
"longitude",
"name"
],
"type": "object"
},
"FoldersResponse": {
"properties": {
"enabled": {
@@ -22847,20 +22593,6 @@
},
"type": "object"
},
"UpdateFavoriteLocationDto": {
"properties": {
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"name": {
"type": "string"
}
},
"type": "object"
},
"UpdateLibraryDto": {
"properties": {
"exclusionPatterns": {
@@ -790,22 +790,6 @@ export type ValidateLibraryImportPathResponseDto = {
export type ValidateLibraryResponseDto = {
importPaths?: ValidateLibraryImportPathResponseDto[];
};
export type FavoriteLocationResponseDto = {
id: string;
latitude: number | null;
longitude: number | null;
name: string;
};
export type CreateFavoriteLocationDto = {
latitude: number;
longitude: number;
name: string;
};
export type UpdateFavoriteLocationDto = {
latitude?: number;
longitude?: number;
name?: string;
};
export type MapMarkerResponseDto = {
city: string | null;
country: string | null;
@@ -3098,59 +3082,6 @@ export function validate({ id, validateLibraryDto }: {
body: validateLibraryDto
})));
}
/**
* Get favorite locations
*/
export function getFavoriteLocations(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: FavoriteLocationResponseDto[];
}>("/map/favorite-locations", {
...opts
}));
}
/**
* Create favorite location
*/
export function createFavoriteLocation({ createFavoriteLocationDto }: {
createFavoriteLocationDto: CreateFavoriteLocationDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: FavoriteLocationResponseDto;
}>("/map/favorite-locations", oazapfts.json({
...opts,
method: "POST",
body: createFavoriteLocationDto
})));
}
/**
* Delete favorite location
*/
export function deleteFavoriteLocation({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/map/favorite-locations/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Update favorite location
*/
export function updateFavoriteLocation({ id, updateFavoriteLocationDto }: {
id: string;
updateFavoriteLocationDto: UpdateFavoriteLocationDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: FavoriteLocationResponseDto;
}>(`/map/favorite-locations/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: updateFavoriteLocationDto
})));
}
/**
* Retrieve map markers
*/
+107 -107
View File
@@ -15,9 +15,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
"cpu": [
"x64"
],
@@ -467,9 +467,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -480,32 +480,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.1"
"@esbuild/aix-ppc64": "0.27.0",
"@esbuild/android-arm": "0.27.0",
"@esbuild/android-arm64": "0.27.0",
"@esbuild/android-x64": "0.27.0",
"@esbuild/darwin-arm64": "0.27.0",
"@esbuild/darwin-x64": "0.27.0",
"@esbuild/freebsd-arm64": "0.27.0",
"@esbuild/freebsd-x64": "0.27.0",
"@esbuild/linux-arm": "0.27.0",
"@esbuild/linux-arm64": "0.27.0",
"@esbuild/linux-ia32": "0.27.0",
"@esbuild/linux-loong64": "0.27.0",
"@esbuild/linux-mips64el": "0.27.0",
"@esbuild/linux-ppc64": "0.27.0",
"@esbuild/linux-riscv64": "0.27.0",
"@esbuild/linux-s390x": "0.27.0",
"@esbuild/linux-x64": "0.27.0",
"@esbuild/netbsd-arm64": "0.27.0",
"@esbuild/netbsd-x64": "0.27.0",
"@esbuild/openbsd-arm64": "0.27.0",
"@esbuild/openbsd-x64": "0.27.0",
"@esbuild/openharmony-arm64": "0.27.0",
"@esbuild/sunos-x64": "0.27.0",
"@esbuild/win32-arm64": "0.27.0",
"@esbuild/win32-ia32": "0.27.0",
"@esbuild/win32-x64": "0.27.0"
}
},
"node_modules/typescript": {
+613 -621
View File
File diff suppressed because it is too large Load Diff
+1 -61
View File
@@ -1,12 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
CreateFavoriteLocationDto,
FavoriteLocationResponseDto,
UpdateFavoriteLocationDto,
} from 'src/dtos/favorite-location.dto';
import {
MapMarkerDto,
MapMarkerResponseDto,
@@ -44,59 +39,4 @@ export class MapController {
reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise<MapReverseGeocodeResponseDto[]> {
return this.service.reverseGeocode(dto);
}
@Authenticated()
@Get('favorite-locations')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get favorite locations',
description: "Retrieve a list of user's favorite locations.",
history: new HistoryBuilder().added('v2').stable('v2'),
})
getFavoriteLocations(@Auth() auth: AuthDto): Promise<FavoriteLocationResponseDto[]> {
return this.service.getFavoriteLocations(auth);
}
@Authenticated()
@Post('favorite-locations')
@HttpCode(HttpStatus.CREATED)
@Endpoint({
summary: 'Create favorite location',
description: 'Create a new favorite location for the user.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
createFavoriteLocation(
@Auth() auth: AuthDto,
@Body() dto: CreateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
return this.service.createFavoriteLocation(auth, dto);
}
@Authenticated()
@Put('favorite-locations/:id')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Update favorite location',
description: 'Update an existing favorite location.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
updateFavoriteLocation(
@Auth() auth: AuthDto,
@Param('id') id: string,
@Body() dto: UpdateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
return this.service.updateFavoriteLocation(auth, id, dto);
}
@Authenticated()
@Delete('favorite-locations/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete favorite location',
description: 'Delete a favorite location by its ID.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
deleteFavoriteLocation(@Param('id') id: string) {
return this.service.deleteFavoriteLocation(id);
}
}
-10
View File
@@ -274,16 +274,6 @@ export type AssetFace = {
updateId: string;
};
export type FavoriteLocation = {
id: string;
name: string;
userId: string;
latitude: number | null;
longitude: number | null;
createdAt: Date;
updatedAt: Date;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
-34
View File
@@ -1,34 +0,0 @@
import { IsLatitude, IsLongitude, IsString } from 'class-validator';
import { Optional } from 'src/validation';
export class CreateFavoriteLocationDto {
@IsString()
name!: string;
@IsLatitude()
latitude!: number;
@IsLongitude()
longitude!: number;
}
export class UpdateFavoriteLocationDto {
@Optional()
@IsString()
name?: string;
@Optional()
@IsLatitude()
latitude?: number;
@Optional()
@IsLongitude()
longitude?: number;
}
export class FavoriteLocationResponseDto {
id!: string;
name!: string;
latitude!: number | null;
longitude!: number | null;
}
-10
View File
@@ -29,13 +29,3 @@ where
)
order by
"fileCreatedAt" desc
-- MapRepository.getFavoriteLocations
select
*
from
"favorite_location"
where
"userId" = $1
order by
"name" asc
+1 -38
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool, Updateable } from 'kysely';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
@@ -12,7 +12,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { DB } from 'src/schema';
import { FavoriteLocationTable } from 'src/schema/tables/favorite-location.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
@@ -139,42 +138,6 @@ export class MapRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFavoriteLocations(userId: string) {
return this.db
.selectFrom('favorite_location')
.selectAll()
.where('userId', '=', userId)
.orderBy('name', 'asc')
.execute();
}
async createFavoriteLocation(entity: Insertable<FavoriteLocationTable>) {
const inserted = await this.db
.insertInto('favorite_location')
.values(entity)
.returningAll()
.executeTakeFirstOrThrow();
return inserted;
}
async updateFavoriteLocation(id: string, userId: string, updates: Updateable<FavoriteLocationTable>) {
const updated = await this.db
.updateTable('favorite_location')
.set(updates)
.where('id', '=', id)
.where('userId', '=', userId)
.returningAll()
.executeTakeFirstOrThrow();
return updated;
}
async deleteFavoriteLocation(id: string) {
await this.db.deleteFrom('favorite_location').where('id', '=', id).execute();
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
@@ -89,7 +89,6 @@ export class MetadataRepository {
// Enable exiftool LFS to parse metadata for files larger than 2GB.
readArgs: ['-api', 'largefilesupport=1'],
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
taskTimeoutMillis: 2 * 60 * 1000,
});
constructor(private logger: LoggingRepository) {
-3
View File
@@ -39,7 +39,6 @@ import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { FavoriteLocationTable } from 'src/schema/tables/favorite-location.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
@@ -98,7 +97,6 @@ export class ImmichDatabase {
AuditTable,
AssetExifTable,
FaceSearchTable,
FavoriteLocationTable,
GeodataPlacesTable,
LibraryTable,
MemoryTable,
@@ -194,7 +192,6 @@ export interface DB {
audit: AuditTable;
face_search: FaceSearchTable;
favorite_location: FavoriteLocationTable;
geodata_places: GeodataPlacesTable;
@@ -1,20 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "favorite_location" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"userId" uuid NOT NULL,
"latitude" double precision,
"longitude" double precision,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "favorite_location_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "favorite_location_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "favorite_location_userId_idx" ON "favorite_location" ("userId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "favorite_location";`.execute(db);
}
@@ -1,35 +0,0 @@
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('favorite_location')
export class FavoriteLocationTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'character varying', nullable: false })
name!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', index: true })
userId!: string;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}
-88
View File
@@ -1,4 +1,3 @@
import { CreateFavoriteLocationDto, UpdateFavoriteLocationDto } from 'src/dtos/favorite-location.dto';
import { MapService } from 'src/services/map.service';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -94,91 +93,4 @@ describe(MapService.name, () => {
expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 });
});
});
describe('getFavoriteLocations', () => {
it('should return favorite locations for the user', async () => {
const favoriteLocation = {
id: 'loc1',
userId: authStub.user1.user.id,
name: 'Home',
latitude: 12.34,
longitude: 56.78,
createdAt: new Date(),
updatedAt: new Date(),
};
mocks.map.getFavoriteLocations.mockResolvedValue([favoriteLocation]);
const result = await sut.getFavoriteLocations(authStub.user1);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(favoriteLocation);
expect(mocks.map.getFavoriteLocations).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('createFavoriteLocation', () => {
it('should create a new favorite location', async () => {
const dto: CreateFavoriteLocationDto = { name: 'Work', latitude: 1, longitude: 2 };
const created = {
id: 'loc2',
userId: authStub.user1.user.id,
name: 'Work',
latitude: 1,
longitude: 2,
createdAt: new Date(),
updatedAt: new Date(),
};
mocks.map.createFavoriteLocation.mockResolvedValue(created);
const result = await sut.createFavoriteLocation(authStub.user1, dto);
expect(result).toEqual(created);
expect(mocks.map.createFavoriteLocation).toHaveBeenCalledWith({
userId: authStub.user1.user.id,
name: dto.name,
latitude: dto.latitude,
longitude: dto.longitude,
});
});
});
describe('updateFavoriteLocation', () => {
it('should update an existing favorite location', async () => {
const dto: UpdateFavoriteLocationDto = { name: 'Gym' };
const updated = {
id: 'loc3',
userId: authStub.user1.user.id,
name: 'Gym',
latitude: null,
longitude: null,
createdAt: new Date(),
updatedAt: new Date(),
};
mocks.map.updateFavoriteLocation.mockResolvedValue(updated);
const result = await sut.updateFavoriteLocation(authStub.user1, 'loc3', dto);
expect(result).toEqual(updated);
expect(mocks.map.updateFavoriteLocation).toHaveBeenCalledWith('loc3', authStub.user1.user.id, {
id: 'loc3',
userId: authStub.user1.user.id,
name: 'Gym',
});
});
});
describe('deleteFavoriteLocation', () => {
it('should call repository to delete a location by id', async () => {
mocks.map.deleteFavoriteLocation.mockResolvedValue();
await sut.deleteFavoriteLocation('loc4');
expect(mocks.map.deleteFavoriteLocation).toHaveBeenCalledWith('loc4');
});
});
});
-39
View File
@@ -1,10 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import {
CreateFavoriteLocationDto,
FavoriteLocationResponseDto,
UpdateFavoriteLocationDto,
} from 'src/dtos/favorite-location.dto';
import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto';
import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util';
@@ -37,38 +32,4 @@ export class MapService extends BaseService {
const result = await this.mapRepository.reverseGeocode({ latitude, longitude });
return result ? [result] : [];
}
async getFavoriteLocations(auth: AuthDto): Promise<FavoriteLocationResponseDto[]> {
return this.mapRepository.getFavoriteLocations(auth.user.id);
}
async createFavoriteLocation(auth: AuthDto, dto: CreateFavoriteLocationDto): Promise<FavoriteLocationResponseDto> {
const entity = {
userId: auth.user.id,
name: dto.name,
latitude: dto.latitude,
longitude: dto.longitude,
};
return this.mapRepository.createFavoriteLocation(entity);
}
async updateFavoriteLocation(
auth: AuthDto,
id: string,
dto: UpdateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
const entity = {
userId: auth.user.id,
id,
...(dto.name !== undefined && { name: dto.name }),
...(dto.latitude !== undefined && { latitude: dto.latitude }),
...(dto.longitude !== undefined && { longitude: dto.longitude }),
};
return this.mapRepository.updateFavoriteLocation(id, auth.user.id, entity);
}
async deleteFavoriteLocation(id: string) {
await this.mapRepository.deleteFavoriteLocation(id);
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:typescript": "tsc --noEmit",
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.45.5",
"svelte": "5.45.2",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -114,11 +114,7 @@
return;
}
await modalManager.show(AssetChangeDateModal, {
asset: toTimelineAsset(asset),
initialDate: dateTime,
initialTimeZone: timeZone,
});
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime });
};
</script>
@@ -8,18 +8,9 @@
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
createFavoriteLocation,
deleteFavoriteLocation,
getFavoriteLocations,
searchPlaces,
type AssetResponseDto,
type FavoriteLocationResponseDto,
type PlacesResponseDto,
} from '@immich/sdk';
import { Button, ConfirmModal, IconButton, Input, LoadingSpinner } from '@immich/ui';
import { mdiDelete, mdiMapMarkerMultipleOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { ConfirmModal, LoadingSpinner } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
@@ -54,22 +45,6 @@
let zoom = $derived(mapLat && mapLng ? 12.5 : 1);
let favoriteLocations: FavoriteLocationResponseDto[] = $state([]);
let newFavoriteName = $state('');
let savingFavorite = $state(false);
const loadFavoriteLocations = async () => {
try {
favoriteLocations = await getFavoriteLocations();
} catch (error) {
handleError(error, 'Failed to load favorite locations');
}
};
onMount(async () => {
await loadFavoriteLocations();
});
$effect(() => {
if (mapElement && initialPoint) {
mapElement.addClipMapMarker(initialPoint.lng, initialPoint.lat);
@@ -93,39 +68,6 @@
}
};
const handleSaveFavorite = async () => {
if (newFavoriteName.trim() === '') {
return;
}
savingFavorite = true;
try {
const newLocation: FavoriteLocationResponseDto = await createFavoriteLocation({
createFavoriteLocationDto: {
name: newFavoriteName,
latitude: point!.lat,
longitude: point!.lng,
},
});
favoriteLocations = [...favoriteLocations, newLocation];
favoriteLocations = favoriteLocations.sort((a, b) => a.name.localeCompare(b.name));
newFavoriteName = '';
} catch (error) {
handleError(error, 'Failed to save favorite location');
} finally {
savingFavorite = false;
}
};
const handleDeleteFavorite = async (locationId: string) => {
try {
await deleteFavoriteLocation({ id: locationId });
favoriteLocations = favoriteLocations.filter((loc) => loc.id !== locationId);
} catch (error) {
handleError(error, 'Failed to delete favorite location');
}
};
const getLocation = (name: string, admin1Name?: string, admin2Name?: string): string => {
return `${name}${admin1Name ? ', ' + admin1Name : ''}${admin2Name ? ', ' + admin2Name : ''}`;
};
@@ -164,13 +106,10 @@
latestSearchTimeout = searchTimeout;
};
const handleUseSuggested = (latitude: number, longitude: number, setZoom?: number) => {
const handleUseSuggested = (latitude: number, longitude: number) => {
hideSuggestion = true;
point = { lng: longitude, lat: latitude };
mapElement?.addClipMapMarker(longitude, latitude);
if (setZoom) {
zoom = setZoom;
}
};
const onUpdate = (lat: number, lng: number) => {
@@ -267,58 +206,6 @@
<div class="grid sm:grid-cols-2 gap-4 text-sm text-start mt-4">
<CoordinatesInput lat={point ? point.lat : assetLat} lng={point ? point.lng : assetLng} {onUpdate} />
</div>
<div class="mt-4">
<div class="flex justify-between items-center gap-2 mb-2">
<p>{$t('favorite_locations')}</p>
<div class="flex gap-2 items-center justify-end">
<Input placeholder={$t('name')} size="tiny" bind:value={newFavoriteName} />
<Button
onclick={handleSaveFavorite}
disabled={newFavoriteName.trim() === '' || savingFavorite || point === null}
variant="outline"
size="tiny"
class="shrink-0">{$t('save')}</Button
>
</div>
</div>
<div class="max-h-40 overflow-y-auto border border-gray-300 dark:border-immich-dark-gray rounded-md p-2">
{#if favoriteLocations.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">{$t('favorite_locations_not_found')}</p>
{:else}
<ul class="space-y-2">
{#each favoriteLocations as location (location.id)}
<li>
<button
type="button"
class="w-full"
onclick={() => handleUseSuggested(location.latitude!, location.longitude!, 14)}
>
<div
class="flex justify-between items-center p-2 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 hover:dark:bg-gray-700"
>
{location.name}
<IconButton
icon={mdiDelete}
shape="round"
variant="outline"
size="medium"
color="danger"
aria-label={$t('delete')}
onclick={async (e: Event) => {
e.stopPropagation();
await handleDeleteFavorite(location.id);
}}
/>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/snippet}
</ConfirmModal>
@@ -188,7 +188,7 @@
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
const monthGroup = await timelineManager.findMonthGroupForAsset({ id: assetId });
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}
@@ -1,5 +1,5 @@
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
import { AssetOrder } from '@immich/sdk';
import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
@@ -7,16 +7,12 @@ import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
export async function getAssetWithOffset(
timelineManager: TimelineManager,
assetDescriptor: AssetDescriptor | AssetResponseDto,
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction,
): Promise<TimelineAsset | undefined> {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetDescriptor);
if (!monthGroup) {
return;
}
const asset = monthGroup.findAssetById(assetDescriptor);
if (!asset) {
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
if (!monthGroup || !asset) {
return;
}
@@ -524,7 +524,6 @@ describe('TimelineManager', () => {
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
sdkMock.getAssetInfo.mockRejectedValue(new Error('Asset not found'));
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
@@ -16,13 +16,12 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
isAssetResponseDto,
setDifference,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
@@ -344,30 +343,27 @@ export class TimelineManager extends VirtualScrollManager {
this.addAssetsUpsertSegments([...notExcluded]);
}
async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
async findMonthGroupForAsset(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
const { id } = asset;
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
if (monthGroup) {
return monthGroup;
}
const response = isAssetResponseDto(asset)
? asset
: await getAssetInfo({ ...authManager.params, id }).catch(() => null);
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
if (!response) {
return;
}
const timelineAsset = toTimelineAsset(response);
if (this.isExcluded(timelineAsset)) {
const asset = toTimelineAsset(response);
if (!asset || this.isExcluded(asset)) {
return;
}
monthGroup = await this.#loadMonthGroupAtTime(timelineAsset.localDateTime, { cancelable: false });
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) {
return monthGroup;
}
@@ -536,14 +532,14 @@ export class TimelineManager extends VirtualScrollManager {
}
async getLaterAsset(
assetDescriptor: AssetDescriptor | AssetResponseDto,
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
}
async getEarlierAsset(
assetDescriptor: AssetDescriptor | AssetResponseDto,
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
+3 -8
View File
@@ -1,4 +1,4 @@
import type { AssetDescriptor, TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
@@ -192,13 +192,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
};
};
export const isTimelineAsset = (
unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
): unknownAsset is TimelineAsset => (unknownAsset as TimelineAsset).ratio !== undefined;
export const isAssetResponseDto = (
unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
): unknownAsset is AssetResponseDto => (unknownAsset as AssetResponseDto).type !== undefined;
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
(unknownAsset as TimelineAsset).ratio !== undefined;
export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] =>
assets.length === 0 || 'ratio' in assets[0];
@@ -15,24 +15,9 @@
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiDotsVertical, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { preferences, user } from '$lib/stores/user.store';
interface Props {
data: PageData;
@@ -94,11 +79,6 @@
// navigate to parent
await navigateToView(tag.parent ? tag.parent.path : '');
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
};
</script>
<UserPageLayout title={data.meta.title}>
@@ -151,45 +131,3 @@
{/if}
</section>
</UserPageLayout>
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{/if}
</section>