Merge branch 'main' into feat/toggle-video-auto-play

This commit is contained in:
Saschl 2025-08-15 08:25:26 +02:00 committed by GitHub
commit 7f095efb1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
212 changed files with 4221 additions and 1106 deletions

View file

@ -20,7 +20,7 @@ jobs:
remove-label: remove-label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview') }} if: ${{ (github.event.action == 'closed' || github.event.pull_request.head.repo.fork) && contains(github.event.pull_request.labels.*.name, 'preview') }}
permissions: permissions:
pull-requests: write pull-requests: write
steps: steps:
@ -33,3 +33,15 @@ jobs:
repo: context.repo.repo, repo: context.repo.repo,
name: 'preview' name: 'preview'
}) })
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ github.event.pull_request.head.repo.fork }}
with:
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
message-id: 'preview-status'
message: 'Preview environment has been removed.'

View file

@ -10,6 +10,9 @@ dev-update:
dev-scale: dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs:
npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
e2e: e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans

6
cli/package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.78",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.78",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@ -54,7 +54,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.137.3", "version": "1.138.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.78",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View file

@ -117,7 +117,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11 image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View file

@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11 image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View file

@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11 image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

2
docs/.gitignore vendored
View file

@ -19,3 +19,5 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
yarn.lock yarn.lock
/static/openapi.json

View file

@ -2,10 +2,6 @@
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich. Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
:::note
The Repair page can take a long time to load. To avoid server timeouts or errors, we recommend specifying a timeout of at least 10 minutes on your proxy server.
:::
:::caution :::caution
Immich does not support being served on a sub-path such as `location /immich {`. It has to be served on the root path of a (sub)domain. Immich does not support being served on a sub-path such as `location /immich {`. It has to be served on the root path of a (sub)domain.
::: :::

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -2,6 +2,9 @@
sidebar_position: 80 sidebar_position: 80
--- ---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# TrueNAS [Community] # TrueNAS [Community]
:::note :::note
@ -9,211 +12,324 @@ This is a community contribution and not officially supported by the Immich team
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/apps/tree/master/trains/community/immich).** **Please report app issues to the corresponding [GitHub Repository](https://github.com/truenas/apps/tree/master/trains/community/immich).**
:::
:::warning
This guide covers the installation of Immich on TrueNAS Community Edition 24.10.2.2 (Electric Eel) and later.
We recommend keeping TrueNAS Community Edition and Immich relatively up to date with the latest versions to avoid any issues.
If you are using an older version of TrueNAS, we ask that you upgrade to the latest version before installing Immich. Check the [TrueNAS Community Edition Release Notes](https://www.truenas.com/docs/softwarereleases/) for more information on breaking changes, new features, and how to upgrade your system.
::: :::
Immich can easily be installed on TrueNAS Community Edition via the **Community** train application. Immich can easily be installed on TrueNAS Community Edition via the **Community** train application.
Consider reviewing the TrueNAS [Apps resources](https://apps.truenas.com/getting-started/) if you have not previously configured applications on your system. Consider reviewing the TrueNAS [Apps resources](https://apps.truenas.com/getting-started/) if you have not previously configured applications on your system.
TrueNAS Community Edition makes installing and updating Immich easy, but you must use the Immich web portal and mobile app to configure accounts and access libraries.
## First Steps ## First Steps
The Immich app in TrueNAS Community Edition installs, completes the initial configuration, then starts the Immich web portal.
When updates become available, TrueNAS alerts and provides easy updates.
Before installing the Immich app in TrueNAS, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
You may also configure environment variables at any time after deploying the application.
### Setting up Storage Datasets ### Setting up Storage Datasets
Before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation. Before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
Immich requires seven datasets: `library`, `upload`, `thumbs`, `profile`, `video`, `backups`, and `pgData`.
You can organize these as one parent with seven child datasets, for example `/mnt/tank/immich/library`, `/mnt/tank/immich/upload`, and so on. In TrueNAS, Immich requires 2 datasets for the application to function correctly: `data` and `pgData`. You can set the datasets to any names to match your naming conventions or preferences.
You can organize these as one parent with two child datasets, for example `/mnt/tank/immich/data` and `/mnt/tank/immich/pgData`.
<img <img
src={require('./img/truenas12.webp').default} src={require('./img/truenas/truenas00.webp').default}
width="30%" width="40%"
alt="Immich App Widget" alt="Immich App Widget"
className="border rounded-xl" className="border rounded-xl"
/> />
:::info Permissions :::info Datasets Permissions
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, Immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017) The **pgData** dataset must be owned by the user `netdata` (UID 999) for Postgres to start.
The `data` dataset must have given the **_modify_** permission to the user who will run Immich.
Since TrueNAS Community Edition 24.10.2.2 and later, Immich can be run as any user and group, the default user being `apps` (UID 568) and the default group being `apps` (GID 568). This user, either `apps` or another user you choose, must have **_modify_** permissions on the **data** dataset.
For an easy setup:
- Create the parent dataset `immich` keeping the default **Generic** preset.
- Select `Dataset Preset` **Apps** instead of **Generic** when creating the `data` dataset. This will automatically give the correct permissions to the dataset. If you want to use another user for Immich, you can keep the **Generic** preset, but you will need to give the **_modify_** permission to that other user.
- For the `pgData` dataset, you can keep the default preset **Generic** as permissions can be set during the installation of the Immich app (See [Storage Configuration](#storage-configuration) section).
:::
:::tip
To improve performance, Immich recommends using SSDs for the database. If you have a pool made of SSDs, you can create the `pgData` dataset on that pool.
Thumbnails can also be stored on the SSDs for faster access. This is an advanced option and not required for Immich to run. More information on how you can use multiple datasets to manage Immich storage in a finer-grained manner can be found in the [Advanced: Multiple Datasets for Immich Storage](#advanced-multiple-datasets-for-immich-storage) section below.
:::
:::warning
If you just created the datasets using the **Apps** preset, you can skip this warning section.
If the **data** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/scale/scaletutorials/datasets/permissionsscale/) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library** (internal folder created by Immich within the **data** dataset), Immich performs `chmod` internally and must be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
To change or verify the ACL mode, go to the **Datasets** screen, select the **library** dataset, click on the **Edit** button next to **Dataset Details**, then click on the **Advanced Options** tab, scroll down to the **ACL Mode** section, and select `Passthrough` from the dropdown menu. Click **Save** to apply the changes. If the option is greyed out, set the **ACL Type** to `SMB/NFSv4` first, then you can change the **ACL Mode** to `Passthrough`.
::: :::
## Installing the Immich Application ## Installing the Immich Application
To install the **Immich** application, go to **Apps**, click **Discover Apps**, either begin typing Immich into the search field or scroll down to locate the **Immich** application widget. To install the **Immich** application, go to **Apps**, click **Discover Apps**, and either begin typing Immich into the search field or scroll down to locate the **Immich** application widget.
<div style={{ marginBottom: '2rem', border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
Click on the widget to open the **Immich** application details screen.
<img <img
src={require('./img/truenas01.webp').default} src={require('./img/truenas/truenas01.webp').default}
width="50%" width="50%"
alt="Immich App Widget" alt="Immich App Widget"
className="border rounded-xl" className="border rounded-xl"
/> />
Click on the widget to open the **Immich** application details screen. </div>
<br/><br/> <div style={{ marginBottom: '2rem', border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
Click **Install** to open the Immich application configuration screen.
<img <img
src={require('./img/truenas02.webp').default} src={require('./img/truenas/truenas02.webp').default}
width="100%" width="100%"
alt="Immich App Details Screen" alt="Immich App Details Screen"
className="border rounded-xl" className="border rounded-xl"
/> />
Click **Install** to open the Immich application configuration screen. </div>
<br/><br/>
Application configuration settings are presented in several sections, each explained below. Application configuration settings are presented in several sections, each explained below.
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner. To find specific fields, click in the **Search Input Fields** search field, scroll down to a particular section, or click on the section heading on the navigation area in the upper-right corner.
### Application Name and Version ### Application Name and Version
<img <img
src={require('./img/truenas03.webp').default} src={require('./img/truenas/truenas03.webp').default}
width="100%" width="100%"
alt="Install Immich Screen" alt="Install Immich Screen"
className="border rounded-xl" className="border rounded-xl mb-4"
/> />
Accept the default value or enter a name in **Application Name** field. Keep the default value or enter a name in the **Application Name** field.
In most cases use the default name, but if adding a second deployment of the application you must change this name. Change it if youre deploying a second instance.
Accept the default version number in **Version**. Immich version within TrueNAS catalog (Different from Immich release version).
When a new version becomes available, the application has an update badge.
The **Installed Applications** screen shows the option to update applications.
### Immich Configuration ### Immich Configuration
<img <img
src={require('./img/truenas05.webp').default} src={require('./img/truenas/truenas04.webp').default}
width="40%" width="40%"
alt="Configuration Settings" alt="Configuration Settings"
className="border rounded-xl mb-4"
/>
The **Timezone** is set to the system default, which usually matches your local timezone. You can change it to another timezone if you prefer.
**Enable Machine Learning** is enabled by default. It allows Immich to use machine learning features such as face recognition, image search, and smart duplicate detection. Untick this option if you do not want to use these features.
Select the **Machine Learning Image Type** based on the hardware you have. More details here: [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md)
**Database Password** should be set to a custom value using only the characters `A-Za-z0-9`. This password is used to secure the Postgres database.
**Redis Password** should be set to a custom value using only the characters `A-Za-z0-9`. Preferably, use a different password from the database password.
Keep the **Log Level** to the default `Log` value.
Leave **Hugging Face Endpoint** blank. (This is used to download ML models from a different source.)
Set **Database Storage Type** to the type of storage (**HDD** or **SSD**) that the pool where the **pgData** dataset is located uses.
**Additional Environment Variables** can be left blank.
<details>
<summary>Advanced users: Adding Environment Variables</summary>
Environment variables can be set by clicking the **Add** button and filling in the **Name** and **Value** fields.
<img
src={require('./img/truenas/truenas05.webp').default}
width="40%"
alt="Environment Variables"
className="border rounded-xl" className="border rounded-xl"
/> />
Accept the default value in **Timezone** or change to match your local timezone. These are used to add custom configuration options or to enable specific features.
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata. More information on available environment variables can be found in the **[environment variables documentation](/docs/install/environment-variables/)**.
Untick **Enable Machine Learning** if you will not use face recognition, image search, and smart duplicate detection. :::info
Some environment variables are not available for the TrueNAS Community Edition app as they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
Accept the default option or select the **Machine Learning Image Type** for your hardware based on the [Hardware-Accelerated Machine Learning Supported Backends](/docs/features/ml-hardware-acceleration.md#supported-backends). Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
:::
Immich's default is `postgres` but you should consider setting the **Database Password** to a custom value using only the characters `A-Za-z0-9`. </details>
The **Redis Password** should be set to a custom value using only the characters `A-Za-z0-9`. ### User and Group Configuration
Accept the **Log Level** default of **Log**. Application in TrueNAS runs as a specific user and group. Immich uses the default user `apps` (UID 568) and the default group `apps` (GID 568).
Leave **Hugging Face Endpoint** blank. (This is for downloading ML models from a different source.) <img
src={require('./img/truenas/truenas06.webp').default}
width="40%"
alt="User and Group Configuration"
className="border rounded-xl"
/>
Leave **Additional Environment Variables** blank or see [Environment Variables](#environment-variables) to set before installing. - **User ID**: Keep the default value `apps` (UID 568) or define a different one if needed.
- **Group ID**: Keep the default value `apps` (GID 568) or define a different one if needed.
:::warning
If you change the user or group, make sure that the datasets you created for Immich data storage have the correct permissions set for that user and group as specified in the [Setting up Storage Datasets](#setting-up-storage-datasets) section above.
:::
### Network Configuration ### Network Configuration
<img <img
src={require('./img/truenas06.webp').default} src={require('./img/truenas/truenas07.webp').default}
width="40%" width="40%"
alt="Networking Settings" alt="Networking Settings"
className="border rounded-xl" className="border rounded-xl"
/> />
Accept the default port `30041` in **WebUI Port** or enter a custom port number. - **Port Bind Mode**: This lets you expose the port to the host system, allowing you to access Immich from outside the TrueNAS system. Keep the default **_Publish port on the host for external access_** value unless you have a specific reason to change it.
:::info Allowed Port Numbers
Only numbers within the range 9000-65535 may be used on TrueNAS versions below TrueNAS Community Edition 24.10 Electric Eel.
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/solutions/optimizations/security/#truenas-default-ports). - **Port Number**: Keep the default port `30041` or enter a custom port number.
:::
- **Host IPs**: Leave the default blank value.
### Storage Configuration ### Storage Configuration
Immich requires seven storage datasets. :::danger Default Settings (Not recommended)
The default setting for datasets is **ixVolume (dataset created automatically by the system)**. This is not recommended as this results in your data being harder to access manually and can result in data loss if you delete the immich app. It is also harder to manage snapshots and replication tasks. It is recommended to use the **Host Path (Path that already exists on the system)** option instead.
<img
src={require('./img/truenas07.webp').default}
width="20%"
alt="Configure Storage ixVolumes"
className="border rounded-xl"
/>
:::note Default Setting (Not recommended)
The default setting for datasets is **ixVolume (dataset created automatically by the system)** but this results in your data being harder to access manually and can result in data loss if you delete the immich app. (Not recommended)
::: :::
For each Storage option select **Host Path (Path that already exists on the system)** and then select the matching dataset [created before installing the app](#setting-up-storage-datasets): **Immich Library Storage**: `library`, **Immich Uploads Storage**: `upload`, **Immich Thumbs Storage**: `thumbs`, **Immich Profile Storage**: `profile`, **Immich Video Storage**: `video`, **Immich Backups Storage**: `backups`, **Postgres Data Storage**: `pgData`. The storage configuration section allows you to set up the storage locations for Immich data. You can select the datasets created in the previous step.
<img <img
src={require('./img/truenas08.webp').default} src={require('./img/truenas/truenas08.webp').default}
width="40%" width="40%"
alt="Configure Storage Host Paths" alt="Configure Storage Volumes"
className="border rounded-xl" className="border rounded-xl"
/> />
The image above has example values.
<br/> For the Data Storage, select **Host Path (Path that already exists on the system)** and then select the dataset you created for Immich data storage, for example, `data`.
### Additional Storage [(External Libraries)](/docs/features/libraries) The Machine Learning cache can be left with default _Temporary_
For the Postgres Data Storage, select **Host Path (Path that already exists on the system)** and then select the dataset you created for Postgres data storage, for example, `pgData`.
:::info
**Postgres Data Storage**
Once **Host Path** is selected, a checkbox appears with **_Automatic Permissions_**. If you have not set the ownership of the **pgData** dataset to `netdata` (UID 999), tick this box as it will set the user ownership to `netdata` (UID 999) and the group ownership to `docker` (GID 999) automatically. If you have set the ownership of the **pgData** dataset to `netdata` (UID 999), you can leave this box unticked.
:::
### Additional Storage (Advanced Users)
<details>
<summary>External Libraries</summary>
:::danger Advanced Users Only :::danger Advanced Users Only
This feature should only be used by advanced users. If this is your first time installing Immich, then DO NOT mount an external library until you have a working setup. Also, your mount path MUST be something unique and should NOT be your library or upload location or a Linux directory like `/lib`. The picture below shows a valid example. This feature should only be used by advanced users. If this is your first time installing Immich, then DO NOT mount an external library until you have a working setup.
::: :::
<img <img
src={require('./img/truenas10.webp').default} src={require('./img/truenas/truenas09.webp').default}
width="40%" width="40%"
alt="Configure Storage Host Paths" alt="Add External Libraries with Additional Storage"
className="border rounded-xl" className="border rounded-xl"
/> />
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**. You may configure [external libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich.
The **Host Path** is the location on the TrueNAS Community Edition server where your external library is located.
<!-- A section for Labels would go here but I don't know what they do. --> The dataset that contains your external library files must at least give **read** access to the user running Immich (Default: `apps` (UID 568), `apps` (GID 568)).
If you want to be able to delete files or edit metadata in the external library using Immich, you will need to give the **modify** permission to the user running Immich.
- **Mount Path** is the location you will need to copy and paste into the external library settings within Immich.
- **Host Path** is the location on the TrueNAS Community Edition server where your external library is located.
- **Read Only** is a checkbox that you can tick if you want to prevent Immich from modifying the files in the external library. This is useful if you want to use Immich to view and search your external library without modifying it.
:::warning
Each mount path MUST be something unique and should NOT be your library or upload location or a Linux directory like `/lib`.
A general recommendation is to mount any external libraries to a path beginning with `/mnt` or `/media` followed by a unique name, such as `/mnt/external-libraries` or `/media/my-external-libraries`. If you plan to mount multiple external libraries, you can use paths like `/mnt/external-libraries/library1`, `/mnt/external-libraries/library2`, etc.
:::
</details>
<details>
<summary>Multiple Datasets for Immich Storage</summary>
:::danger Advanced Users Only
This feature should only be used by advanced users.
:::
Immich can use multiple datasets for its storage, allowing you to manage your data more granularly, similar to the old storage configuration. This is useful if you want to separate your data into different datasets for performance or organizational reasons. There is a general guide for this [here](/docs/guides/custom-locations), but read on for the TrueNAS guide.
Each additional dataset has to give the permission **_modify_** to the user who will run Immich (Default: `apps` (UID 568), `apps` (GID 568))
As described in the [Setting up Storage Datasets](#setting-up-storage-datasets) section above, you have to create the datasets with the **Apps** preset to ensure the correct permissions are set, or you can set the permissions manually after creating the datasets.
Immich uses 6 folders for its storage: `library`, `upload`, `thumbs`, `profile`, `encoded-video`, and `backups`. You can create a dataset for each of these folders or only for some of them.
To mount these datasets:
1. Add an **Additional Storage** entry for each dataset you want to use.
2. Select **Type** as **Host Path (Path that already exists on the system)**.
3. Enter the **Mount Path** with `/data/<folder-name>`. The `<folder-name>` is the name of the folder you want to mount, for example, `library`, `upload`, `thumbs`, `profile`, `encoded-video`, or `backups`.
:::danger Important
You have to write the full path, including `/data/`, as Immich expects the data to be in that location.
If you do not include this path, Immich will not be able to find the data and will not write the data to the location you specified.
:::
4. Select the **Host Path** as the dataset you created for that folder, for example, `/mnt/tank/immich/library`, `/mnt/tank/immich/upload`, etc.
<img
src={require('./img/truenas/truenas10.webp').default}
width="40%"
alt="Use Multiple Datasets for Immich Storage with Additional Storage"
className="border rounded-xl"
/>
</details>
<!-- A section for Labels could be added, but I don't think it is needed as they are of no use for Immich. -->
### Resources Configuration ### Resources Configuration
<img <img
src={require('./img/truenas09.webp').default} src={require('./img/truenas/truenas11.webp').default}
width="40%" width="40%"
alt="Resource Limits"
className="border rounded-xl" className="border rounded-xl"
/> />
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core). - **CPU**: Depending on your system resources, you can keep the default value of `2` threads or specify a different number. Immich recommends at least `8` threads.
Specify the **Memory** limit in MB of RAM. Immich recommends at least 6000 MB (6GB). If you selected **Enable Machine Learning** in **Immich Configuration**, you should probably set this above 8000 MB. - **Memory**: Limit in MB of RAM. Immich recommends at least 6000 MB (6GB). If you selected **Enable Machine Learning** in **Immich Configuration**, you should probably set this above 8000 MB.
:::info Older TrueNAS Versions Both **CPU** and **Memory** are limits, not reservations. This means that Immich can use up to the specified amount of CPU threads and RAM, but it will not reserve that amount of resources at all times. The system will allocate resources as needed, and Immich will use less than the specified amount most of the time.
Before TrueNAS Community Edition version 24.10 Electric Eel:
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads. - Enable **GPU Configuration** options if you have a GPU or CPU with integrated graphics that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md).
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000` The process for NVIDIA GPU passthrough requires additional steps.
::: More details here: [GPU Passthrough Docs for TrueNAS Apps](https://apps.truenas.com/managing-apps/installing-apps/#gpu-passthrough)
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://apps.truenas.com/managing-apps/installing-apps/#gpu-passthrough)
### Install ### Install
Finally, click **Install**. Finally, click **Install**.
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state. The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
When the installation completes it changes to **Running**. When the installation completes, it changes to **Running**.
<img <img
src={require('./img/truenas04.webp').default} src={require('./img/truenas/truenas12.webp').default}
width="100%" width="100%"
alt="Immich Installed" alt="Immich Installed"
className="border rounded-xl" className="border rounded-xl"
/> />
Click **Web Portal** on the **Application Info** widget to open the Immich web interface to set up your account and begin uploading photos. Click **Web Portal** on the **Application Info** widget, or go to the URL `http://<your-truenas-ip>:30041` in your web browser to open the Immich web interface. This will show you the onboarding process to set up your first user account, which will be an administrator account.
After that, you can start using Immich to upload and manage your photos and videos.
:::tip :::tip
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide. For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
@ -228,23 +344,6 @@ For more information on how to use the application once installed, please refer
- Click **Update** at the very bottom of the page to save changes. - Click **Update** at the very bottom of the page to save changes.
- TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated settings. - TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated settings.
## Environment Variables
You can set [Environment Variables](/docs/install/environment-variables) by clicking **Add** on the **Additional Environment Variables** option and filling in the **Name** and **Value**.
<img
src={require('./img/truenas11.webp').default}
width="40%"
alt="Environment Variables"
className="border rounded-xl"
/>
:::info
Some Environment Variables are not available for the TrueNAS Community Edition app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
:::
## Updating the App ## Updating the App
:::danger :::danger
@ -261,3 +360,116 @@ To update the app to the latest version:
- You may view the Changelog. - You may view the Changelog.
- Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress. - Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress.
- When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date. - When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
## Migration
:::danger
Perform a backup of your Immich data before proceeding with the migration steps below. This is crucial to prevent any data loss if something goes wrong during the migration process.
The migration should also be performed when the Immich app is not running to ensure no data is being written while you are copying the data.
:::
### Migration from Old Storage Configuration
There are two ways to migrate from the old storage configuration to the new one, depending on whether you want to keep the old multiple datasets or if you want to move to a double dataset configuration with a single dataset for Immich data storage and a single dataset for Postgres data storage.
:::note Old TrueNAS Versions Permissions
If you were using an older version of TrueNAS (before 24.10.2.2), the datasets, except the one for **pgData** had only to be owned by the `root` user (UID 0). You might have to add the **modify** permission to the `apps` user (UID 568) or the user you want to run Immich as, to all of them, except **pgData**. The steps to add or change ACL permissions are described in the [TrueNAS documentation](https://www.truenas.com/docs/scale/scaletutorials/datasets/permissionsscale/).
:::
<Tabs groupId="truenas-migration-tabs">
<TabItem value="migrate-new-dataset" label="Migrate data to a new dataset (recommended)" default>
To migrate from the old storage configuration to the new one, you will need to create a new dataset for the Immich data storage and copy the data from the old datasets to the new ones. The steps are as follows:
1. **Stop the Immich app** from the TrueNAS web interface to ensure no data is being written while you are copying the data.
2. **Create a new dataset** for the Immich data storage, for example, `data`. As described in the [Setting up Storage Datasets](#setting-up-storage-datasets) section above, create the dataset with the **Apps** preset to ensure the correct permissions are set.
3. **Copy the data** from the old datasets to the new dataset. We advise using the `rsync` command to copy the data, as it will preserve the permissions and ownership of the files. The following commands are examples:
```bash
rsync -av /mnt/tank/immich/library/ /mnt/tank/immich/data/library/
rsync -av /mnt/tank/immich/upload/ /mnt/tank/immich/data/upload/
rsync -av /mnt/tank/immich/thumbs/ /mnt/tank/immich/data/thumbs/
rsync -av /mnt/tank/immich/profile/ /mnt/tank/immich/data/profile/
rsync -av /mnt/tank/immich/video/ /mnt/tank/immich/data/encoded-video/
rsync -av /mnt/tank/immich/backups/ /mnt/tank/immich/data/backups/
```
Make sure to replace `/mnt/tank/immich/` with the correct path to your old datasets and `/mnt/tank/immich/data/` with the correct path to your new dataset.
:::tip
If you were using **ixVolume (dataset created automatically by the system)** for Immich data storage, the path to the data should be `/mnt/.ix-apps/app_mounts/immich/`. You have to use this path instead of `/mnt/tank/immich/` in the `rsync` command above, for example:
```bash
rsync -av /mnt/.ix-apps/app_mounts/immich/library/ /mnt/tank/immich/data/library/
```
If you were also using an ixVolume for Postgres data storage, you also should, first create the pgData dataset, as described in the [Setting up Storage Datasets](#setting-up-storage-datasets) section above, and then you can use the following command to copy the Postgres data:
```bash
rsync -av /mnt/.ix-apps/app_mounts/immich/pgData/ /mnt/tank/immich/pgData/
```
:::
:::warning
Make sure that for each folder, the `.immich` file is copied as well, as it contains important metadata for Immich. If for some reason the `.immich` file is not copied, you can copy it manually with the `rsync` command, for example:
```bash
rsync -av /mnt/tank/immich/library/.immich /mnt/tank/immich/data/library/
```
Replace `library` with the name of the folder where you are copying the file.
:::
4. **Update the permissions** as the permissions of the data that have been copied has been preserved, to ensure that the `apps` user (UID 568) has the correct permissions on all the copied data. If you just created the dataset with the **Apps** preset, from the TrueNAS web interface, go to the **Datasets** screen, select the **data** dataset, click on the **Edit** button next to **Permissions**, tick the "Apply permissions recursively" checkbox, and click **Save**. This will apply the correct permissions to all the copied data.
5. **Update the Immich app** to use the new dataset:
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
- Click **Edit** on the **Application Info** widget.
- In the **Storage Configuration** section, untick the **Use Old Storage Configuration (Deprecated)** checkbox.
- For the **Data Storage**, select **Host Path (Path that already exists on the system)** and then select the new dataset you created for Immich data storage, for example, `data`.
- For the **Postgres Data Storage**, verify that it is still set to the dataset you created for Postgres data storage, for example, `pgData`.
- Click **Update** at the bottom of the page to save changes.
6. **Start the Immich app** from the TrueNAS web interface.
This will recreate the Immich container with the new storage configuration and start the app.
If everything went well, you should now be able to access Immich with the new storage configuration. You can verify that the data has been copied correctly by checking the Immich web interface and ensuring that all your photos and videos are still available. You may delete the old datasets, if you no longer need them, using the TrueNAS web interface.
If you were using **ixVolume (dataset created automatically by the system)** or folders for Immich data storage, you can delete the old datasets using the following commands:
```bash
rm -r /mnt/.ix-apps/app_mounts/immich/library
rm -r /mnt/.ix-apps/app_mounts/immich/uploads
rm -r /mnt/.ix-apps/app_mounts/immich/thumbs
rm -r /mnt/.ix-apps/app_mounts/immich/profile
rm -r /mnt/.ix-apps/app_mounts/immich/video
rm -r /mnt/.ix-apps/app_mounts/immich/backups
```
</TabItem>
<TabItem value="migrate-old-dataset" label="Keep the existing datasets">
To migrate from the old storage configuration to the new one without creating new datasets.
1. **Stop the Immich app** from the TrueNAS web interface to ensure no data is being written while you are updating the app.
2. **Update the datasets permissions**: Ensure that the datasets used for Immich data storage (`library`, `upload`, `thumbs`, `profile`, `video`, `backups`) have the correct permissions set for the user who will run Immich. The user should have ***modify*** permissions on these datasets. The default user for Immich is `apps` (UID 568) and the default group is `apps` (GID 568). If you are using a different user, make sure to set the permissions accordingly. You can do this from the TrueNAS web interface by going to the **Datasets** screen, selecting each dataset, clicking on the **Edit** button next to **Permissions**, and adding the user with ***modify*** permissions.
3. **Update the Immich app** to use the existing datasets:
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
- Click **Edit** on the **Application Info** widget.
- In the **Storage Configuration** section, untick the **Use Old Storage Configuration (Deprecated)** checkbox.
- For the **Data Storage**, you can keep the **ixVolume (dataset created automatically by the system)** as no data will be directly written to it. We recommend selecting **Host Path (Path that already exists on the system)** and then select a **new** dataset you created for Immich data storage, for example, `data`.
- For the **Postgres Data Storage**, keep **Host Path (Path that already exists on the system)** and then select the existing dataset you used for Postgres data storage, for example, `pgData`.
- Following the instructions in the [Multiple Datasets for Immich Storage](#additional-storage-advanced-users) section, you can add, **for each old dataset**, a new Additional Storage with the following settings:
- **Type**: `Host Path (Path that already exists on the system)`
- **Mount Path**: `/data/<folder-name>` (e.g. `/data/library`)
- **Host Path**: `/mnt/<your-pool-name>/<dataset-name>` (e.g. `/mnt/tank/immich/library`)
:::danger Ensure using the correct paths names
Make sure to replace `<folder-name>` with the actual name of the folder used by Immich: `library`, `upload`, `thumbs`, `profile`, `encoded-video`, and `backups`. Also, replace `<your-pool-name>` and `<dataset-name>` with the actual names of your pool and dataset.
:::
- **Read Only**: Keep it unticked as Immich needs to write to these datasets.
- Click **Update** at the bottom of the page to save changes.
4. **Start the Immich app** from the TrueNAS web interface. This will recreate the Immich container with the new storage configuration and start the app. If everything went well, you should now be able to access Immich with the new storage configuration. You can verify that the data is still available by checking the Immich web interface and ensuring that all your photos and videos are still accessible.
</TabItem>
</Tabs>

View file

@ -27,3 +27,102 @@ docker image prune
[watchtower]: https://containrrr.dev/watchtower/ [watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[releases]: https://github.com/immich-app/immich/releases [releases]: https://github.com/immich-app/immich/releases
## Migrating to VectorChord
:::info
If you deploy Immich using Docker Compose, see `ghcr.io/immich-app/postgres` in the `docker-compose.yml` file and have not explicitly set the `DB_VECTOR_EXTENSION` environmental variable, your Immich database is already using VectorChord and this section does not apply to you.
:::
:::important
If you do not deploy Immich using Docker Compose and see a deprecation warning for pgvecto.rs on server startup, you should refer to the maintainers of the Immich distribution for guidance (if using a turnkey solution) or adapt the instructions for your specific setup.
:::
Immich has migrated off of the deprecated pgvecto.rs database extension to its successor, [VectorChord](https://github.com/tensorchord/VectorChord), which comes with performance improvements in almost every aspect. This section will guide you on how to make this change in a Docker Compose setup.
Before making any changes, please [back up your database](/docs/administration/backup-and-restore). While every effort has been made to make this migration as smooth as possible, theres always a chance that something can go wrong.
After making a backup, please modify your `docker-compose.yml` file with the following information.
```diff
[...]
database:
container_name: immich_postgres
- image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
+ # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
+ # DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
- healthcheck:
- test: >-
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
- echo "checksum failure count is $$Chksum";
- [ "$$Chksum" = '0' ] || exit 1
- interval: 5m
- start_interval: 30s
- start_period: 5m
- command: >-
- postgres
- -c shared_preload_libraries=vectors.so
- -c 'search_path="$$user", public, vectors'
- -c logging_collector=on
- -c max_wal_size=2GB
- -c shared_buffers=512MB
- -c wal_compression=on
+ shm_size: 128mb
restart: always
[...]
```
:::important
If you deviated from the defaults of pg14 or pgvectors0.2.0, you must adjust the pg major version and pgvecto.rs version. If you are still using the default `docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0` image, you can just follow the changes above. For example, if the previous image is `docker.io/tensorchord/pgvecto-rs:pg16-v0.3.0`, the new image should be `ghcr.io/immich-app/postgres:16-vectorchord0.3.0-pgvectors0.3.0` instead of the image specified in the diff.
:::
After making these changes, you can start Immich as normal. Immich will make some changes to the DB during startup, which can take seconds to minutes to finish, depending on hardware and library size. In particular, its normal for the server logs to be seemingly stuck at `Reindexing clip_index` and `Reindexing face_index`for some time if you have over 100k assets in Immich and/or Immich is on a relatively weak server. If you see these logs and there are no errors, just give it time.
:::danger
After switching to VectorChord, you should not downgrade Immich below 1.133.0.
:::
Please dont hesitate to contact us on [GitHub](https://github.com/immich-app/immich/discussions) or [Discord](https://discord.immich.app/) if you encounter migration issues.
### VectorChord FAQ
#### I have a separate PostgreSQL instance shared with multiple services. How can I switch to VectorChord?
Please see the [standalone PostgreSQL documentation](/docs/administration/postgres-standalone#migrating-to-vectorchord) for migration instructions. The migration path will be different depending on whether youre currently using pgvecto.rs or pgvector, as well as whether Immich has superuser DB permissions.
#### Why are so many lines removed from the `docker-compose.yml` file? Does this mean the health check is removed?
These lines are now incorporated into the image itself along with some additional tuning.
#### What does this change mean for my existing DB backups?
The new DB image includes pgvector and pgvecto.rs in addition to VectorChord, so you can use this image to restore from existing backups that used either of these extensions. However, backups made after switching to VectorChord require an image containing VectorChord to restore successfully.
#### Do I still need pgvecto.rs installed after migrating to VectorChord?
pgvecto.rs only needs to be available during the migration, or if you need to restore from a backup that used pgvecto.rs. For a leaner DB and a smaller image, you can optionally switch to an image variant that doesnt have pgvecto.rs installed after youve performed the migration and started Immich: `ghcr.io/immich-app/postgres:14-vectorchord0.4.3`, changing the PostgreSQL version as appropriate.
#### Why does it matter whether my database is on an SSD or an HDD?
These storage mediums have different performance characteristics. As a result, the optimal settings for an SSD are not the same as those for an HDD. Either configuration is compatible with SSD and HDD, but using the right configuration will make Immich snappier. As a general tip, we recommend users store the database on an SSD whenever possible.
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
Its a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.

View file

@ -7,7 +7,8 @@
"format": "prettier --check .", "format": "prettier --check .",
"format:fix": "prettier --write .", "format:fix": "prettier --write .",
"start": "docusaurus start --port 3005", "start": "docusaurus start --port 3005",
"build": "docusaurus build", "copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "npm run copy:openapi && docusaurus build",
"swizzle": "docusaurus swizzle", "swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy", "deploy": "docusaurus deploy",
"clear": "docusaurus clear", "clear": "docusaurus clear",

View file

@ -16,6 +16,9 @@ import {
mdiCloudKeyOutline, mdiCloudKeyOutline,
mdiRegex, mdiRegex,
mdiCodeJson, mdiCodeJson,
mdiClockOutline,
mdiAccountOutline,
mdiRestart,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
@ -26,6 +29,42 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date }; type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
const items: Item[] = [ const items: Item[] = [
{
icon: mdiClockOutline,
iconColor: 'gray',
title: 'setTimeout is cursed',
description:
'The setTimeout method in JavaScript is cursed when used with small values because the implementation may or may not actually wait the specified time.',
link: {
url: 'https://github.com/immich-app/immich/pull/20655',
text: '#20655',
},
date: new Date(2025, 7, 4),
},
{
icon: mdiAccountOutline,
iconColor: '#DAB1DA',
title: 'PostgreSQL USER is cursed',
description:
'The USER keyword in PostgreSQL is cursed because you can select from it like a table, which leads to confusion if you have a table name user as well.',
link: {
url: 'https://github.com/immich-app/immich/pull/19891',
text: '#19891',
},
date: new Date(2025, 7, 4),
},
{
icon: mdiRestart,
iconColor: '#8395e3',
title: 'PostgreSQL RESET is cursed',
description:
'PostgreSQL RESET is cursed because it is impossible to RESET a PostgreSQL extension parameter if the extension has been uninstalled.',
link: {
url: 'https://github.com/immich-app/immich/pull/19363',
text: '#19363',
},
date: new Date(2025, 5, 20),
},
{ {
icon: mdiRegex, icon: mdiRegex,
iconColor: 'purple', iconColor: 'purple',

View file

@ -1,4 +1,8 @@
[ [
{
"label": "v1.138.0",
"url": "https://v1.138.0.archive.immich.app"
},
{ {
"label": "v1.137.3", "label": "v1.137.3",
"url": "https://v1.137.3.archive.immich.app" "url": "https://v1.137.3.archive.immich.app"

8
e2e/package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.137.3", "version": "1.138.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.137.3", "version": "1.138.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@ -46,7 +46,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.77", "version": "2.2.78",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@ -95,7 +95,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.137.3", "version": "1.138.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.137.3", "version": "1.138.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View file

@ -683,7 +683,7 @@ describe('/albums', () => {
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ role: AlbumUserRole.Editor }); .send({ role: AlbumUserRole.Editor });
expect(status).toBe(200); expect(status).toBe(204);
// Get album to verify the role change // Get album to verify the role change
const { body } = await request(app) const { body } = await request(app)

View file

@ -555,7 +555,7 @@ describe('/asset', () => {
expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null }); expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null });
}); });
it('should update date time original when sidecar file contains DateTimeOriginal', async () => { it.skip('should update date time original when sidecar file contains DateTimeOriginal', async () => {
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?> const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'> <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'> <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
@ -854,6 +854,30 @@ describe('/asset', () => {
}); });
}); });
describe('PUT /assets', () => {
it('should update date time original relatively', async () => {
const { status, body } = await request(app)
.put(`/assets/`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Assets[0].id], dateTimeRelative: -1441 });
expect(body).toEqual({});
expect(status).toEqual(204);
const result = await request(app)
.get(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send();
expect(result.body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-19T01:10:00+00:00',
}),
});
});
});
describe('POST /assets', () => { describe('POST /assets', () => {
beforeAll(setupTests, 30_000); beforeAll(setupTests, 30_000);

View file

@ -116,7 +116,7 @@ describe('/partners', () => {
.delete(`/partners/${user3.userId}`) .delete(`/partners/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(204);
}); });
it('should throw a bad request if partner not found', async () => { it('should throw a bad request if partner not found', async () => {

View file

@ -485,7 +485,7 @@ describe('/shared-links', () => {
.delete(`/shared-links/${linkWithAlbum.id}`) .delete(`/shared-links/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(204);
}); });
}); });
}); });

View file

@ -304,7 +304,7 @@ describe('/users', () => {
const { status } = await request(app) const { status } = await request(app)
.delete(`/users/me/license`) .delete(`/users/me/license`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`); .set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(204);
}); });
}); });
}); });

View file

@ -355,6 +355,9 @@
"trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them", "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them",
"trash_settings": "Trash Settings", "trash_settings": "Trash Settings",
"trash_settings_description": "Manage trash settings", "trash_settings_description": "Manage trash settings",
"unlink_all_oauth_accounts": "Unlink all OAuth accounts",
"unlink_all_oauth_accounts_description": "Remember to unlink all OAuth accounts before migrating to a new provider.",
"unlink_all_oauth_accounts_prompt": "Are you sure you want to unlink all OAuth accounts? This will reset the OAuth ID for each user and cannot be undone.",
"user_cleanup_job": "User cleanup", "user_cleanup_job": "User cleanup",
"user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
"user_delete_delay_settings": "Delete delay", "user_delete_delay_settings": "Delete delay",
@ -749,6 +752,7 @@
"date_of_birth_saved": "Date of birth saved successfully", "date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range", "date_range": "Date range",
"day": "Day", "day": "Day",
"days": "Days",
"deduplicate_all": "Deduplicate All", "deduplicate_all": "Deduplicate All",
"deduplication_criteria_1": "Image size in bytes", "deduplication_criteria_1": "Image size in bytes",
"deduplication_criteria_2": "Count of EXIF data", "deduplication_criteria_2": "Count of EXIF data",
@ -833,10 +837,12 @@
"edit": "Edit", "edit": "Edit",
"edit_album": "Edit album", "edit_album": "Edit album",
"edit_avatar": "Edit avatar", "edit_avatar": "Edit avatar",
"edit_birthday": "Edit Birthday", "edit_birthday": "Edit birthday",
"edit_date": "Edit date", "edit_date": "Edit date",
"edit_date_and_time": "Edit date and time", "edit_date_and_time": "Edit date and time",
"edit_date_and_time_action_prompt": "{count} date and time edited", "edit_date_and_time_action_prompt": "{count} date and time edited",
"edit_date_and_time_by_offset": "Change date by offset",
"edit_date_and_time_by_offset_interval": "New date range: {from} - {to}",
"edit_description": "Edit description", "edit_description": "Edit description",
"edit_description_prompt": "Please select a new description:", "edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern", "edit_exclusion_pattern": "Edit exclusion pattern",
@ -909,6 +915,7 @@
"failed_to_load_notifications": "Failed to load notifications", "failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people", "failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key", "failed_to_remove_product_key": "Failed to remove product key",
"failed_to_reset_pin_code": "Failed to reset PIN code",
"failed_to_stack_assets": "Failed to stack assets", "failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status", "failed_to_update_notification_status": "Failed to update notification status",
@ -917,6 +924,7 @@
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size", "quota_higher_than_disk_size": "You set a quota higher than the disk size",
"something_went_wrong": "Something went wrong",
"unable_to_add_album_users": "Unable to add users to album", "unable_to_add_album_users": "Unable to add users to album",
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link", "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment", "unable_to_add_comment": "Unable to add comment",
@ -1007,9 +1015,6 @@
"exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location": "LOCATION",
"exif_bottom_sheet_people": "PEOPLE", "exif_bottom_sheet_people": "PEOPLE",
"exif_bottom_sheet_person_add_person": "Add name", "exif_bottom_sheet_person_add_person": "Add name",
"exif_bottom_sheet_person_age_months": "Age {months} months",
"exif_bottom_sheet_person_age_year_months": "Age 1 year, {months} months",
"exif_bottom_sheet_person_age_years": "Age {years}",
"exit_slideshow": "Exit Slideshow", "exit_slideshow": "Exit Slideshow",
"expand_all": "Expand all", "expand_all": "Expand all",
"experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_subtitle": "Work in progress",
@ -1056,6 +1061,7 @@
"folder_not_found": "Folder not found", "folder_not_found": "Folder not found",
"folders": "Folders", "folders": "Folders",
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward", "forward": "Forward",
"gcast_enabled": "Google Cast", "gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.", "gcast_enabled_description": "This feature loads external resources from Google in order to work.",
@ -1110,6 +1116,7 @@
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"host": "Host", "host": "Host",
"hour": "Hour", "hour": "Hour",
"hours": "Hours",
"id": "ID", "id": "ID",
"idle": "Idle", "idle": "Idle",
"ignore_icloud_photos": "Ignore iCloud photos", "ignore_icloud_photos": "Ignore iCloud photos",
@ -1188,6 +1195,7 @@
"library_page_sort_title": "Album title", "library_page_sort_title": "Album title",
"licenses": "Licenses", "licenses": "Licenses",
"light": "Light", "light": "Light",
"like": "Like",
"like_deleted": "Like deleted", "like_deleted": "Like deleted",
"link_motion_video": "Link motion video", "link_motion_video": "Link motion video",
"link_to_oauth": "Link to OAuth", "link_to_oauth": "Link to OAuth",
@ -1298,6 +1306,7 @@
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}", "merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
"minimize": "Minimize", "minimize": "Minimize",
"minute": "Minute", "minute": "Minute",
"minutes": "Minutes",
"missing": "Missing", "missing": "Missing",
"model": "Model", "model": "Model",
"month": "Month", "month": "Month",
@ -1371,6 +1380,7 @@
"oauth": "OAuth", "oauth": "OAuth",
"official_immich_resources": "Official Immich Resources", "official_immich_resources": "Official Immich Resources",
"offline": "Offline", "offline": "Offline",
"offset": "Offset",
"ok": "Ok", "ok": "Ok",
"oldest_first": "Oldest first", "oldest_first": "Oldest first",
"on_this_device": "On this device", "on_this_device": "On this device",
@ -1448,6 +1458,9 @@
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
"permission_onboarding_request": "Immich requires permission to view your photos and videos.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"person": "Person", "person": "Person",
"person_age_months": "{months, plural, one {# month} other {# months}} old",
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}", "person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.", "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
@ -1593,6 +1606,9 @@
"reset_password": "Reset password", "reset_password": "Reset password",
"reset_people_visibility": "Reset people visibility", "reset_people_visibility": "Reset people visibility",
"reset_pin_code": "Reset PIN code", "reset_pin_code": "Reset PIN code",
"reset_pin_code_description": "If you forgot your PIN code, you can contact the server administrator to reset it",
"reset_pin_code_success": "Successfully reset PIN code",
"reset_pin_code_with_password": "You can always reset your PIN code with your password",
"reset_sqlite": "Reset SQLite Database", "reset_sqlite": "Reset SQLite Database",
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data", "reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
"reset_sqlite_success": "Successfully reset the SQLite database", "reset_sqlite_success": "Successfully reset the SQLite database",
@ -1843,6 +1859,7 @@
"sort_created": "Date created", "sort_created": "Date created",
"sort_items": "Number of items", "sort_items": "Number of items",
"sort_modified": "Date modified", "sort_modified": "Date modified",
"sort_newest": "Newest photo",
"sort_oldest": "Oldest photo", "sort_oldest": "Oldest photo",
"sort_people_by_similarity": "Sort people by similarity", "sort_people_by_similarity": "Sort people by similarity",
"sort_recent": "Most recent photo", "sort_recent": "Most recent photo",

View file

@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:ce3b954c9285a7a145cba620bae03db836ab890b6b9e0d05a3ca522ea00dfbc9 AS builder-cpu FROM python:3.11-bookworm@sha256:85c4ac66dea23fbd1beb5c48957c2589d104002f8b11c90a186be421117da5e0 AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino
@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++ RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest@sha256:cda9608307dbbfc1769f3b6b1f9abf5f1360de0be720f544d29a7ae2863c47ef /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi fi
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:01f98e2d213e1cda58a21dabfd107c4a71c99caa0c932c593acfce05315b7251 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-openvino FROM python:3.11-slim-bookworm@sha256:01f98e2d213e1cda58a21dabfd107c4a71c99caa0c932c593acfce05315b7251 AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

View file

@ -36,7 +36,7 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]:
def normalize( def normalize(
img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32] img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32]
) -> NDArray[np.float32]: ) -> NDArray[np.float32]:
return np.divide(img - mean, std, dtype=np.float32) return (img - mean) / std
def get_pil_resampling(resample: str) -> Image.Resampling: def get_pil_resampling(resample: str) -> Image.Resampling:
@ -58,10 +58,12 @@ def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image:
def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]: def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]:
if isinstance(image_bytes, bytes): match image_bytes:
image_bytes = decode_pil(image_bytes) # pillow is much faster than cv2 case bytes() | memoryview() | bytearray():
if isinstance(image_bytes, Image.Image): return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2
case Image.Image():
return pil_to_cv2(image_bytes) return pil_to_cv2(image_bytes)
case _:
return image_bytes return image_bytes

View file

@ -112,8 +112,4 @@ def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict) return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
def is_ndarray(obj: Any, dtype: "type[np._DTypeScalar_co]") -> "TypeGuard[npt.NDArray[np._DTypeScalar_co]]":
return isinstance(obj, np.ndarray) and obj.dtype == dtype
T = TypeVar("T") T = TypeVar("T")

View file

@ -12,6 +12,7 @@ dependencies = [
"gunicorn>=21.1.0", "gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0", "huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0", "insightface>=0.7.3,<1.0",
"numpy<2",
"opencv-python-headless>=4.7.0.72,<5.0", "opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5", "orjson>=3.9.5",
"pillow>=9.5.0,<11.0", "pillow>=9.5.0,<11.0",

761
machine-learning/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" <application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true" android:enableOnBackInvokedCallback="false"> android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3002, "android.injected.version.code" => 3003,
"android.injected.version.name" => "1.137.3", "android.injected.version.name" => "1.138.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@ -40,16 +41,14 @@ class ImmichTestHelper {
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
// Clear all data from Isar (reuse existing instance if available) // Clear all data from Isar (reuse existing instance if available)
final db = await Bootstrap.initIsar(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb);
await Store.clear(); await Store.clear();
await db.writeTxn(() => db.clear()); await db.writeTxn(() => db.clear());
// Load main Widget // Load main Widget
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)],
dbProvider.overrideWithValue(db),
isarProvider.overrideWithValue(db),
],
child: const app.MainWidget(), child: const app.MainWidget(),
), ),
); );
@ -59,18 +58,11 @@ class ImmichTestHelper {
} }
@isTest @isTest
void immichWidgetTest( void immichWidgetTest(String description, Future<void> Function(WidgetTester, ImmichTestHelper) test) {
String description, testWidgets(description, (widgetTester) async {
Future<void> Function(WidgetTester, ImmichTestHelper) test,
) {
testWidgets(
description,
(widgetTester) async {
await ImmichTestHelper.loadApp(widgetTester); await ImmichTestHelper.loadApp(widgetTester);
await test(widgetTester, ImmichTestHelper(widgetTester)); await test(widgetTester, ImmichTestHelper(widgetTester));
}, }, semanticsEnabled: false);
semanticsEnabled: false,
);
} }
Future<void> pumpUntilFound( Future<void> pumpUntilFound(
@ -79,8 +71,7 @@ Future<void> pumpUntilFound(
Duration timeout = const Duration(seconds: 120), Duration timeout = const Duration(seconds: 120),
}) async { }) async {
bool found = false; bool found = false;
final timer = final timer = Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
while (found != true) { while (found != true) {
await tester.pump(); await tester.pump();
found = tester.any(finder); found = tester.any(finder);

View file

@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
) )
increment_version_number( increment_version_number(
version_number: "1.137.3" version_number: "1.138.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View file

@ -23,6 +23,7 @@ class RemoteAlbum {
final AlbumAssetOrder order; final AlbumAssetOrder order;
final int assetCount; final int assetCount;
final String ownerName; final String ownerName;
final bool isShared;
const RemoteAlbum({ const RemoteAlbum({
required this.id, required this.id,
@ -36,6 +37,7 @@ class RemoteAlbum {
required this.order, required this.order,
required this.assetCount, required this.assetCount,
required this.ownerName, required this.ownerName,
required this.isShared,
}); });
@override @override
@ -52,6 +54,7 @@ class RemoteAlbum {
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"} thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
assetCount: $assetCount assetCount: $assetCount
ownerName: $ownerName ownerName: $ownerName
isShared: $isShared
}'''; }''';
} }
@ -69,7 +72,8 @@ class RemoteAlbum {
isActivityEnabled == other.isActivityEnabled && isActivityEnabled == other.isActivityEnabled &&
order == other.order && order == other.order &&
assetCount == other.assetCount && assetCount == other.assetCount &&
ownerName == other.ownerName; ownerName == other.ownerName &&
isShared == other.isShared;
} }
@override @override
@ -84,7 +88,8 @@ class RemoteAlbum {
isActivityEnabled.hashCode ^ isActivityEnabled.hashCode ^
order.hashCode ^ order.hashCode ^
assetCount.hashCode ^ assetCount.hashCode ^
ownerName.hashCode; ownerName.hashCode ^
isShared.hashCode;
} }
RemoteAlbum copyWith({ RemoteAlbum copyWith({
@ -99,6 +104,7 @@ class RemoteAlbum {
AlbumAssetOrder? order, AlbumAssetOrder? order,
int? assetCount, int? assetCount,
String? ownerName, String? ownerName,
bool? isShared,
}) { }) {
return RemoteAlbum( return RemoteAlbum(
id: id ?? this.id, id: id ?? this.id,
@ -112,6 +118,7 @@ class RemoteAlbum {
order: order ?? this.order, order: order ?? this.order,
assetCount: assetCount ?? this.assetCount, assetCount: assetCount ?? this.assetCount,
ownerName: ownerName ?? this.ownerName, ownerName: ownerName ?? this.ownerName,
isShared: isShared ?? this.isShared,
); );
} }
} }

View file

@ -1,12 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
class RemoteAlbumService { class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository; final DriftRemoteAlbumRepository _repository;
@ -26,8 +26,21 @@ class RemoteAlbumService {
return _repository.get(albumId); return _repository.get(albumId);
} }
List<RemoteAlbum> sortAlbums(List<RemoteAlbum> albums, RemoteAlbumSortMode sortMode, {bool isReverse = false}) { Future<List<RemoteAlbum>> sortAlbums(
return sortMode.sortFn(albums, isReverse); List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) async {
final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
return (isReverse ? sorted.reversed : sorted).toList();
} }
List<RemoteAlbum> searchAlbums( List<RemoteAlbum> searchAlbums(
@ -143,4 +156,60 @@ class RemoteAlbumService {
Future<int> getCount() { Future<int> getCount() {
return _repository.getCount(); return _repository.getCount();
} }
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title"),
assetCount("library_page_sort_asset_count"),
lastModified("library_page_sort_last_modified"),
created("library_page_sort_created"),
mostRecent("sort_newest"),
mostOldest("sort_oldest");
final String key;
const RemoteAlbumSortMode(this.key);
} }

View file

@ -169,6 +169,36 @@ class TimelineService {
return _buffer.elementAt(index - _bufferOffset); return _buffer.elementAt(index - _bufferOffset);
} }
/// Gets an asset at the given index, automatically loading the buffer if needed.
/// This is an async version that can handle out-of-range indices by loading the appropriate buffer.
Future<BaseAsset?> getAssetAsync(int index) async {
if (index < 0 || index >= _totalAssets) {
return null;
}
if (hasRange(index, 1)) {
return _buffer.elementAt(index - _bufferOffset);
}
// Load the buffer containing the requested index
try {
final assets = await loadAssets(index, 1);
return assets.isNotEmpty ? assets.first : null;
} catch (e) {
return null;
}
}
/// Safely gets an asset at the given index without throwing a RangeError.
/// Returns null if the index is out of bounds or not currently in the buffer.
/// For automatic buffer loading, use getAssetAsync instead.
BaseAsset? getAssetSafe(int index) {
if (index < 0 || index >= _totalAssets || !hasRange(index, 1)) {
return null;
}
return _buffer.elementAt(index - _bufferOffset);
}
Future<void> dispose() async { Future<void> dispose() async {
await _bucketSubscription?.cancel(); await _bucketSubscription?.cancel();
_bucketSubscription = null; _bucketSubscription = null;

View file

@ -95,7 +95,7 @@ class ExifInfo {
); );
} }
@TableIndex(name: 'idx_lat_lng', columns: {#latitude, #longitude}) @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)')
class RemoteExifEntity extends Table with DriftDefaultsMixin { class RemoteExifEntity extends Table with DriftDefaultsMixin {
const RemoteExifEntity(); const RemoteExifEntity();

View file

@ -701,7 +701,7 @@ typedef $$RemoteExifEntityTableProcessedTableManager =
>; >;
i0.Index get idxLatLng => i0.Index( i0.Index get idxLatLng => i0.Index(
'idx_lat_lng', 'idx_lat_lng',
'CREATE INDEX idx_lat_lng ON remote_exif_entity (latitude, longitude)', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
); );
class $RemoteExifEntityTable extends i2.RemoteExifEntity class $RemoteExifEntityTable extends i2.RemoteExifEntity

View file

@ -4,7 +4,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.d
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex(name: 'idx_local_asset_checksum', columns: {#checksum}) @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity(); const LocalAssetEntity();

View file

@ -338,7 +338,7 @@ typedef $$LocalAssetEntityTableProcessedTableManager =
>; >;
i0.Index get idxLocalAssetChecksum => i0.Index( i0.Index get idxLocalAssetChecksum => i0.Index(
'idx_local_asset_checksum', 'idx_local_asset_checksum',
'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)', 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
); );
class $LocalAssetEntityTable extends i3.LocalAssetEntity class $LocalAssetEntityTable extends i3.LocalAssetEntity

View file

@ -5,7 +5,9 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex(name: 'idx_remote_asset_owner_checksum', columns: {#ownerId, #checksum}) @TableIndex.sql(
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
)
@TableIndex.sql(''' @TableIndex.sql('''
CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum
ON remote_asset_entity (owner_id, checksum) ON remote_asset_entity (owner_id, checksum)
@ -16,7 +18,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum
ON remote_asset_entity (owner_id, library_id, checksum) ON remote_asset_entity (owner_id, library_id, checksum)
WHERE (library_id IS NOT NULL); WHERE (library_id IS NOT NULL);
''') ''')
@TableIndex(name: 'idx_remote_asset_checksum', columns: {#checksum}) @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)')
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const RemoteAssetEntity(); const RemoteAssetEntity();

View file

@ -628,7 +628,7 @@ typedef $$RemoteAssetEntityTableProcessedTableManager =
>; >;
i0.Index get idxRemoteAssetOwnerChecksum => i0.Index( i0.Index get idxRemoteAssetOwnerChecksum => i0.Index(
'idx_remote_asset_owner_checksum', 'idx_remote_asset_owner_checksum',
'CREATE INDEX idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
); );
class $RemoteAssetEntityTable extends i3.RemoteAssetEntity class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
@ -1641,5 +1641,5 @@ i0.Index get uQRemoteAssetsOwnerLibraryChecksum => i0.Index(
); );
i0.Index get idxRemoteAssetChecksum => i0.Index( i0.Index get idxRemoteAssetChecksum => i0.Index(
'idx_remote_asset_checksum', 'idx_remote_asset_checksum',
'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
); );

View file

@ -106,10 +106,13 @@ class Drift extends $Drift implements IDatabaseRepository {
from5To6: (m, v6) async { from5To6: (m, v6) async {
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum) // Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum'); await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
await m.drop(v6.idxRemoteAssetOwnerChecksum);
await m.create(v6.idxRemoteAssetOwnerChecksum); await m.create(v6.idxRemoteAssetOwnerChecksum);
// Adds libraryId to remote_asset_entity // Adds libraryId to remote_asset_entity
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId); await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum); await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum); await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
}, },
from6To7: (m, v7) async { from6To7: (m, v7) async {

View file

@ -2847,11 +2847,11 @@ final class Schema7 extends i0.VersionedSchema {
); );
final i1.Index idxLocalAssetChecksum = i1.Index( final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum', 'idx_local_asset_checksum',
'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)', 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
); );
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum', 'idx_remote_asset_owner_checksum',
'CREATE INDEX idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
); );
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum', 'UQ_remote_assets_owner_checksum',
@ -2863,7 +2863,7 @@ final class Schema7 extends i0.VersionedSchema {
); );
final i1.Index idxRemoteAssetChecksum = i1.Index( final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum', 'idx_remote_asset_checksum',
'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
); );
late final Shape4 userMetadataEntity = Shape4( late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable( source: i0.VersionedTable(
@ -3045,7 +3045,7 @@ final class Schema7 extends i0.VersionedSchema {
); );
final i1.Index idxLatLng = i1.Index( final i1.Index idxLatLng = i1.Index(
'idx_lat_lng', 'idx_lat_lng',
'CREATE INDEX idx_lat_lng ON remote_exif_entity (latitude, longitude)', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
); );
} }

View file

@ -31,11 +31,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
useColumns: false, useColumns: false,
), ),
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false), leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]); ]);
query query
..where(_db.remoteAssetEntity.deletedAt.isNull()) ..where(_db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount]) ..addColumns([assetCount])
..addColumns([_db.userEntity.name]) ..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]); ..groupBy([_db.remoteAlbumEntity.id]);
if (sortBy.isNotEmpty) { if (sortBy.isNotEmpty) {
@ -53,7 +59,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.map( .map(
(row) => row (row) => row
.readTable(_db.remoteAlbumEntity) .readTable(_db.remoteAlbumEntity)
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!), .toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
) )
.get(); .get();
} }
@ -78,17 +88,27 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false, useColumns: false,
), ),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]) ])
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull()) ..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount]) ..addColumns([assetCount])
..addColumns([_db.userEntity.name]) ..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]); ..groupBy([_db.remoteAlbumEntity.id]);
return query return query
.map( .map(
(row) => row (row) => row
.readTable(_db.remoteAlbumEntity) .readTable(_db.remoteAlbumEntity)
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!), .toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
) )
.getSingleOrNull(); .getSingleOrNull();
} }
@ -254,24 +274,57 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false, useColumns: false,
), ),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
]) ])
..where(_db.remoteAlbumEntity.id.equals(albumId)) ..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([_db.userEntity.name]) ..addColumns([_db.userEntity.name])
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
..groupBy([_db.remoteAlbumEntity.id]); ..groupBy([_db.remoteAlbumEntity.id]);
return query.map((row) { return query.map((row) {
final album = row.readTable(_db.remoteAlbumEntity).toDto(ownerName: row.read(_db.userEntity.name)!); final album = row
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
);
return album; return album;
}).watchSingleOrNull(); }).watchSingleOrNull();
} }
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
}
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
}
Future<int> getCount() { Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count(); return _db.managers.remoteAlbumEntity.count();
} }
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {
RemoteAlbum toDto({int assetCount = 0, required String ownerName}) { RemoteAlbum toDto({int assetCount = 0, required String ownerName, required bool isShared}) {
return RemoteAlbum( return RemoteAlbum(
id: id, id: id,
name: name, name: name,
@ -284,6 +337,7 @@ extension on RemoteAlbumEntityData {
order: order, order: order,
assetCount: assetCount, assetCount: assetCount,
ownerName: ownerName, ownerName: ownerName,
isShared: isShared,
); );
} }
} }

View file

@ -258,7 +258,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
); );
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder( TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) => row.deletedAt.isNull() & row.isFavorite.equals(true) & row.ownerId.equals(userId), filter: (row) =>
row.deletedAt.isNull() &
row.isFavorite.equals(true) &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
groupBy: groupBy, groupBy: groupBy,
); );

View file

@ -14,6 +14,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
@ -41,7 +42,8 @@ import 'package:worker_manager/worker_manager.dart';
void main() async { void main() async {
ImmichWidgetsBinding(); ImmichWidgetsBinding();
final db = await Bootstrap.initIsar(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb);
await initApp(); await initApp();
// Warm-up isolate pool for worker manager // Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true); await workerManager.init(dynamicSpawning: true);

View file

@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -39,6 +40,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
return; return;
} }
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id); await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
} }

View file

@ -0,0 +1,104 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
import 'package:immich_mobile/providers/activity.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/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@RoutePage()
class DriftActivitiesPage extends HookConsumerWidget {
const DriftActivitiesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider)!;
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
final listViewScrollController = useScrollController();
void scrollToBottom() {
listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent + 80,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
}
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
scrollToBottom();
}
return Scaffold(
appBar: AppBar(
title: asset == null ? Text(album.name) : null,
actions: [const LikeActivityActionButton(menuItem: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(
onData: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
);
return SafeArea(
child: Stack(
children: [
ListView.builder(
controller: listViewScrollController,
itemCount: data.length + 1,
itemBuilder: (context, index) {
if (index == data.length) {
return const SizedBox(height: 80);
}
final activity = data[index];
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
return Padding(
padding: const EdgeInsets.all(5),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier.removeActivity(activity.id)
: null,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
),
child: DriftActivityTextField(
isEnabled: album.isActivityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
),
),
],
),
);
},
),
resizeToAvoidBottomInset: true,
);
}
}

View file

@ -64,6 +64,7 @@ class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
await handleEditBirthday(context); await handleEditBirthday(context);
context.pop(); context.pop();
}, },
birthdayExists: _person.birthDate != null,
); );
}, },
); );

View file

@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bot
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -164,6 +165,10 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
} }
} }
Future<void> showActivity(BuildContext context) async {
context.pushRoute(const DriftActivitiesRoute());
}
void showOptionSheet(BuildContext context) { void showOptionSheet(BuildContext context) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false; final isOwner = user != null ? user.id == _album.ownerId : false;
@ -215,7 +220,18 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProviderScope( return PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
Future.microtask(() {
if (mounted) {
ref.read(currentRemoteAlbumProvider.notifier).dispose();
ref.read(remoteAlbumProvider.notifier).refresh();
}
});
}
},
child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
@ -229,9 +245,11 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onShowOptions: () => showOptionSheet(context), onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(), onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context), onEditTitle: () => showEditTitleAndDescription(context),
onActivity: () => showActivity(context),
), ),
bottomSheet: RemoteAlbumBottomSheet(album: _album), bottomSheet: RemoteAlbumBottomSheet(album: _album),
), ),
),
); );
} }
} }

View file

@ -0,0 +1,174 @@
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View file

@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
ref.read(backgroundSyncProvider).syncLocal(full: true);
context.navigator.popUntil((route) => route.isFirst);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
onPress() {
if (currentAsset == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.navigator.push(
MaterialPageRoute(
builder: (context) => DriftEditImagePage(asset: currentAsset, image: image, isEdited: false),
),
);
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:collection/collection.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.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/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.menuItem = false});
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider);
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
onTap(Activity? liked) async {
if (user == null) {
return;
}
if (liked != null) {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
} else {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
}
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
}
return activities.when(
data: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
);
return BaseActionButton(
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
label: "like".t(context: context),
onPressed: () => onTap(liked),
menuItem: menuItem,
);
},
// default to empty heart during loading
loading: () => BaseActionButton(
iconData: Icons.favorite_border,
label: "like".t(context: context),
menuItem: menuItem,
),
error: (error, stack) => Text("Error: $error"),
);
}
}

View file

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@ -18,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
@ -137,21 +138,28 @@ class _SortButton extends ConsumerStatefulWidget {
class _SortButtonState extends ConsumerState<_SortButton> { class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
bool albumSortIsReverse = true; bool albumSortIsReverse = true;
bool isSorting = false;
void onMenuTapped(RemoteAlbumSortMode sortMode) { Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode; final selected = albumSortOption == sortMode;
// Switch direction // Switch direction
if (selected) { if (selected) {
setState(() { setState(() {
albumSortIsReverse = !albumSortIsReverse; albumSortIsReverse = !albumSortIsReverse;
isSorting = true;
}); });
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse); await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} else { } else {
setState(() { setState(() {
albumSortOption = sortMode; albumSortOption = sortMode;
isSorting = true;
}); });
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse); await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} }
setState(() {
isSorting = false;
});
} }
@override @override
@ -229,6 +237,16 @@ class _SortButtonState extends ConsumerState<_SortButton> {
color: context.colorScheme.onSurface.withAlpha(225), color: context.colorScheme.onSurface.withAlpha(225),
), ),
), ),
isSorting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colorScheme.onSurface.withAlpha(225),
),
)
: const SizedBox.shrink(),
], ],
), ),
); );
@ -423,10 +441,7 @@ class _AlbumList extends ConsumerWidget {
sliver: SliverList.builder( sliver: SliverList.builder(
itemBuilder: (_, index) { itemBuilder: (_, index) {
final album = albums[index]; final album = albums[index];
final albumTile = LargeLeadingTile(
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
title: Text( title: Text(
album.name, album.name,
maxLines: 2, maxLines: 2,
@ -457,9 +472,42 @@ class _AlbumList extends ConsumerWidget {
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
), ),
), ),
);
final isOwner = album.ownerId == userId;
if (isOwner) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Dismissible(
key: ValueKey(album.id),
background: Container(
color: context.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: context.colorScheme.onError),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) {
return showDialog<bool>(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => true,
title: "delete_album".t(context: context),
content: "album_delete_confirmation".t(context: context, args: {'album': album.name}),
ok: "delete".t(context: context),
), ),
); );
}, },
onDismissed: (direction) async {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
},
child: albumTile,
),
);
} else {
return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile);
}
},
itemCount: albums.length, itemCount: albums.length,
), ),
); );

View file

@ -0,0 +1,109 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class DriftActivityTextField extends ConsumerStatefulWidget {
final bool isEnabled;
final String? likeId;
final Function(String) onSubmit;
final Function()? onKeyboardFocus;
const DriftActivityTextField({
required this.onSubmit,
this.isEnabled = true,
this.likeId,
this.onKeyboardFocus,
super.key,
});
@override
ConsumerState<DriftActivityTextField> createState() => _DriftActivityTextFieldState();
}
class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField> {
late FocusNode inputFocusNode;
late TextEditingController inputController;
bool sendEnabled = false;
@override
void initState() {
super.initState();
inputController = TextEditingController();
inputFocusNode = FocusNode();
inputFocusNode.requestFocus();
inputFocusNode.addListener(() {
if (inputFocusNode.hasFocus) {
widget.onKeyboardFocus?.call();
}
});
inputController.addListener(() {
setState(() {
sendEnabled = inputController.text.trim().isNotEmpty;
});
});
}
@override
void dispose() {
inputController.dispose();
inputFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
// Pass text to callback and reset controller
void onEditingComplete() {
if (inputController.text.trim().isEmpty) {
return;
}
widget.onSubmit(inputController.text);
inputController.clear();
inputFocusNode.unfocus();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: TextField(
controller: inputController,
enabled: widget.isEnabled,
focusNode: inputFocusNode,
textInputAction: TextInputAction.send,
autofocus: false,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12), // Adjust as needed
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
prefixIcon: user != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30, radius: 15),
)
: null,
suffixIcon: IconButton(
onPressed: sendEnabled ? onEditingComplete : null,
icon: const Icon(Icons.send),
iconSize: 24,
color: context.primaryColor,
disabledColor: context.colorScheme.secondaryContainer,
),
hintText: !widget.isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
),
onEditingComplete: onEditingComplete,
onTapOutside: (_) => inputFocusNode.unfocus(),
),
);
}
}

View file

@ -127,20 +127,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_delayedOperations.clear(); _delayedOperations.clear();
} }
// This is used to calculate the scale of the asset when the bottom sheet is showing.
// It is a small increment to ensure that the asset is slightly zoomed in when the
// bottom sheet is showing, which emulates the zoom effect.
double get _getScaleForBottomSheet => (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + 0.01;
double _getVerticalOffsetForBottomSheet(double extent) => double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent); (context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
Future<void> _precacheImage(int index) async { Future<void> _precacheImage(int index) async {
if (!mounted || index < 0 || index >= totalAssets) { if (!mounted) {
return;
}
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null || !mounted) {
return; return;
} }
final asset = ref.read(timelineServiceProvider).getAsset(index);
final screenSize = Size(context.width, context.height); final screenSize = Size(context.width, context.height);
// Precache both thumbnail and full image for smooth transitions // Precache both thumbnail and full image for smooth transitions
@ -152,8 +153,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
); );
} }
void _onAssetChanged(int index) { void _onAssetChanged(int index) async {
final asset = ref.read(timelineServiceProvider).getAsset(index); // Validate index bounds and try to get asset, loading buffer if needed
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null) {
return;
}
// Always holds the current asset from the timeline // Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed // The currentAssetNotifier actually holds the current asset that is displayed
@ -217,19 +225,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final verticalOffset = final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset); controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01;
} }
} }
void _onPageChanged(int index, PhotoViewControllerBase? controller) { void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index); _onAssetChanged(index);
viewController = controller; viewController = controller;
// If the bottom sheet is showing, we need to adjust scale the asset to
// emulate the zoom effect
if (showingBottomSheet) {
initialScale = controller?.scale;
controller?.scale = _getScaleForBottomSheet;
}
} }
void _onDragStart( void _onDragStart(
@ -412,16 +416,22 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
} }
void _onAssetReloadEvent() { void _onAssetReloadEvent() async {
setState(() {
final index = pageController.page?.round() ?? 0; final index = pageController.page?.round() ?? 0;
final newAsset = ref.read(timelineServiceProvider).getAsset(index); final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) {
return;
}
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed // Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) { if (newAsset.heroTag == currentAsset?.heroTag) {
return; return;
} }
setState(() {
_onAssetChanged(pageController.page!.round()); _onAssetChanged(pageController.page!.round());
sheetCloseController?.close(); sheetCloseController?.close();
}); });
@ -430,7 +440,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) { void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true); ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale; initialScale = viewController?.scale;
viewController?.updateMultiple(scale: _getScaleForBottomSheet); // viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
previousExtent = _kBottomSheetMinimumExtent; previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet( sheetCloseController = showBottomSheet(
context: ctx, context: ctx,
@ -468,16 +478,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); final timelineService = ref.read(timelineServiceProvider);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, show a loading container
if (asset == null) {
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
} }
return Container( return Container(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
color: backgroundColor, color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain), child: Thumbnail(asset: displayAsset, fit: BoxFit.contain),
); );
} }
@ -493,18 +516,34 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx; scaffoldContext ??= ctx;
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); final timelineService = ref.read(timelineServiceProvider);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, return a placeholder
if (asset == null) {
return PhotoViewGalleryPageOptions.customChild(
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
child: Container(
width: ctx.width,
height: ctx.height,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
} }
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (asset.isImage && !isPlayingMotionVideo) { if (displayAsset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, asset); return _imageBuilder(ctx, displayAsset);
} }
return _videoBuilder(ctx, asset); return _videoBuilder(ctx, displayAsset);
} }
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
@ -515,8 +554,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999,
minScale: PhotoViewComputedScale.contained * 0.999,
disableScaleGestures: showingBottomSheet, disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
@ -545,9 +582,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown, onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0, maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center, basePosition: Alignment.center,
child: SizedBox( child: SizedBox(
width: ctx.width, width: ctx.width,

View file

@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.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/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/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/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@ -38,6 +39,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer) ? const DeleteLocalActionButton(source: ActionSource.viewer)

View file

@ -8,9 +8,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_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/delete_local_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/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/download_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/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_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/share_action_button.widget.dart';
@ -49,6 +50,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (currentAlbum != null && currentAlbum.isActivityEnabled && currentAlbum.isShared)
const LikeActivityActionButton(),
if (asset.hasRemote) ...[ if (asset.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.viewer), const ShareLinkActionButton(source: ActionSource.viewer),
const ArchiveActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer),

View file

@ -61,7 +61,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
), ),
), ),
SizedBox( SizedBox(
height: 150, height: 160,
child: ListView( child: ListView(
padding: const EdgeInsets.only(left: 16.0), padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,

View file

@ -13,9 +13,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@ -28,12 +28,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final album = ref.watch(currentRemoteAlbumProvider);
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final previousRouteName = ref.watch(previousRouteNameProvider); final previousRouteName = ref.watch(previousRouteNameProvider);
final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null; final showViewInTimelineButton =
previousRouteName != TabShellRoute.name &&
previousRouteName != AssetViewerRoute.name &&
previousRouteName != null;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
@ -44,10 +49,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
} }
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
final actions = <Widget>[ final actions = <Widget>[
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
onPressed: () {
context.navigateTo(const DriftActivitiesRoute());
},
),
if (showViewInTimelineButton) if (showViewInTimelineButton)
IconButton( IconButton(
onPressed: () async { onPressed: () async {
@ -67,7 +78,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
]; ];
final lockedViewActions = <Widget>[ final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(), const _KebabMenu(),
]; ];

View file

@ -36,7 +36,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService; return timelineService;
}), }),
], ],
child: const Timeline(appBar: null, bottomSheet: null), child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
); );
} }
} }

View file

@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/extensions/codec_extensions.dart'; import 'package:immich_mobile/extensions/codec_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
@ -107,7 +106,6 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt), DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
@ -117,13 +115,30 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
} }
// Streams in each stage of the image as we ask for it // Streams in each stage of the image as we ask for it
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
try { try {
return switch (key.type) { // First, yield the thumbnail image from LocalThumbProvider
final thumbProvider = LocalThumbProvider(id: key.id, updatedAt: key.updatedAt);
try {
final thumbCodec = await thumbProvider._codec(
thumbProvider,
thumbProvider.cacheManager ?? ThumbnailImageCacheManager(),
decode,
);
final thumbImageInfo = await thumbCodec.getImageInfo();
yield thumbImageInfo;
} catch (_) {}
// Then proceed with the main image loading stream
final mainStream = switch (key.type) {
AssetType.image => _decodeProgressive(key, decode), AssetType.image => _decodeProgressive(key, decode),
AssetType.video => _getThumbnailCodec(key, decode), AssetType.video => _getThumbnailCodec(key, decode),
_ => throw StateError('Unsupported asset type ${key.type}'), _ => throw StateError('Unsupported asset type ${key.type}'),
}; };
await for (final imageInfo in mainStream) {
yield imageInfo;
}
} catch (error, stack) { } catch (error, stack) {
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack); Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
throw const ImageLoadingException('Could not load image from local storage'); throw const ImageLoadingException('Could not load image from local storage');

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@ -68,11 +69,30 @@ class _DriftMapState extends ConsumerState<DriftMap> {
const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}), const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
); );
if (Platform.isAndroid) {
await controller.addCircleLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
const CircleLayerProperties(
circleRadius: 10,
circleColor: "rgba(150,86,34,0.7)",
circleBlur: 1.0,
circleOpacity: 0.7,
circleStrokeWidth: 0.1,
circleStrokeColor: "rgba(203,46,19,0.5)",
circleStrokeOpacity: 0.7,
),
);
}
if (Platform.isIOS) {
await controller.addHeatmapLayer( await controller.addHeatmapLayer(
MapUtils.defaultSourceId, MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId, MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatmapLayerProperties, MapUtils.defaultHeatmapLayerProperties,
); );
}
_debouncer.run(setBounds); _debouncer.run(setBounds);
controller.addListener(onMapMoved); controller.addListener(onMapMoved);
} }

View file

@ -63,7 +63,6 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
height: 300, height: 300,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)), borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: ScrollDatePicker( child: ScrollDatePicker(
options: DatePickerOptions( options: DatePickerOptions(
backgroundColor: context.colorScheme.surfaceContainerHigh, backgroundColor: context.colorScheme.surfaceContainerHigh,
@ -72,14 +71,17 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
), ),
scrollViewOptions: DatePickerScrollViewOptions( scrollViewOptions: DatePickerScrollViewOptions(
day: ScrollViewDetailOptions( day: ScrollViewDetailOptions(
isLoop: false,
margin: const EdgeInsets.all(12), margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16), selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
), ),
month: ScrollViewDetailOptions( month: ScrollViewDetailOptions(
isLoop: false,
margin: const EdgeInsets.all(12), margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16), selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
), ),
year: ScrollViewDetailOptions( year: ScrollViewDetailOptions(
isLoop: false,
margin: const EdgeInsets.all(12), margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16), selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
), ),

View file

@ -3,10 +3,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
class PersonOptionSheet extends ConsumerWidget { class PersonOptionSheet extends ConsumerWidget {
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday}); const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false});
final VoidCallback? onEditName; final VoidCallback? onEditName;
final VoidCallback? onEditBirthday; final VoidCallback? onEditBirthday;
final bool birthdayExists;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -25,7 +26,7 @@ class PersonOptionSheet extends ConsumerWidget {
), ),
ListTile( ListTile(
leading: const Icon(Icons.cake), leading: const Icon(Icons.cake),
title: Text('edit_birthday'.t(context: context), style: textStyle), title: Text((birthdayExists ? 'edit_birthday' : "add_birthday").t(context: context), style: textStyle),
onTap: onEditBirthday, onTap: onEditBirthday,
), ),
], ],

View file

@ -36,6 +36,7 @@ class Timeline extends StatelessWidget {
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(), this.bottomSheet = const GeneralBottomSheet(),
this.groupBy, this.groupBy,
this.withScrubber = true,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@ -45,6 +46,7 @@ class Timeline extends StatelessWidget {
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withStack; final bool withStack;
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
final bool withScrubber;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -69,6 +71,7 @@ class Timeline extends StatelessWidget {
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber,
), ),
), ),
), ),
@ -77,12 +80,19 @@ class Timeline extends StatelessWidget {
} }
class _SliverTimeline extends ConsumerStatefulWidget { class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight, this.appBar, this.bottomSheet}); const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
this.appBar,
this.bottomSheet,
this.withScrubber = true,
});
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@ -265,6 +275,45 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
const scrubberBottomPadding = 100.0; const scrubberBottomPadding = 100.0;
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding); final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
final grid = CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
);
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: grid,
);
} else {
timeline = grid;
}
return PrimaryScrollController( return PrimaryScrollController(
controller: _scrollController, controller: _scrollController,
child: RawGestureDetector( child: RawGestureDetector(
@ -303,40 +352,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}, },
child: Stack( child: Stack(
children: [ children: [
Scrubber( timeline,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode)
const SelectionSliverAppBar()
else if (widget.appBar != null)
widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
),
if (!isSelectionMode && isMultiSelectEnabled) ...[ if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()), const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!, if (widget.bottomSheet != null) widget.bottomSheet!,

View file

@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
class EnqueueStatus { class EnqueueStatus {
final int enqueueCount; final int enqueueCount;
@ -213,6 +214,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
final UploadService _uploadService; final UploadService _uploadService;
StreamSubscription<TaskStatusUpdate>? _statusSubscription; StreamSubscription<TaskStatusUpdate>? _statusSubscription;
StreamSubscription<TaskProgressUpdate>? _progressSubscription; StreamSubscription<TaskProgressUpdate>? _progressSubscription;
final _logger = Logger("DriftBackupNotifier");
/// Remove upload item from state /// Remove upload item from state
void _removeUploadItem(String taskId) { void _removeUploadItem(String taskId) {
@ -333,18 +335,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
Future<void> handleBackupResume(String userId) async { Future<void> handleBackupResume(String userId) async {
debugPrint("handleBackupResume"); _logger.info("Resuming backup tasks...");
final tasks = await _uploadService.getActiveTasks(kBackupGroup); final tasks = await _uploadService.getActiveTasks(kBackupGroup);
debugPrint("Found ${tasks.length} tasks"); _logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) { if (tasks.isEmpty) {
// Start a new backup queue // Start a new backup queue
debugPrint("Start a new backup queue"); _logger.info("Start a new backup queue");
await startBackup(userId); return startBackup(userId);
} }
debugPrint("Tasks to resume: ${tasks.length}"); _logger.info("Tasks to resume: ${tasks.length}");
await _uploadService.resumeBackup(); return _uploadService.resumeBackup();
} }
@override @override

View file

@ -31,5 +31,6 @@ class CurrentAlbumNotifier extends AutoDisposeNotifier<RemoteAlbum?> {
void dispose() { void dispose() {
_keepAliveLink?.close(); _keepAliveLink?.close();
_assetSubscription?.cancel(); _assetSubscription?.cancel();
state = null;
} }
} }

View file

@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -71,8 +70,8 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
state = state.copyWith(filteredAlbums: state.albums); state = state.copyWith(filteredAlbums: state.albums);
} }
void sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) { Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
final sortedAlbums = _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse); final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums); state = state.copyWith(filteredAlbums: sortedAlbums);
} }

View file

@ -165,6 +165,7 @@ class AlbumApiRepository extends ApiRepository {
order: dto.order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc, order: dto.order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: dto.assetCount, assetCount: dto.assetCount,
ownerName: dto.owner.name, ownerName: dto.owner.name,
isShared: dto.albumUsers.length > 2,
); );
} }
} }

View file

@ -112,6 +112,7 @@ extension on AlbumResponseDto {
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc, order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount, assetCount: assetCount,
ownerName: owner.name, ownerName: owner.name,
isShared: albumUsers.length > 2,
); );
} }
} }

View file

@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType; import 'package:photo_manager/photo_manager.dart' hide AssetType;
@ -15,6 +16,18 @@ class FileMediaRepository {
return AssetMediaRepository.toAsset(entity); return AssetMediaRepository.toAsset(entity);
} }
Future<LocalAsset?> saveLocalAsset(Uint8List data, {required String title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImage(data, filename: title, title: title, relativePath: relativePath);
return LocalAsset(
id: entity.id,
name: title,
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
);
}
Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async { Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath); final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity); return AssetMediaRepository.toAsset(entity);

View file

@ -80,6 +80,7 @@ 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/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.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/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
@ -101,6 +102,9 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
@ -333,6 +337,10 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftEditImageRoute.page),
AutoRoute(page: DriftCropImageRoute.page),
AutoRoute(page: DriftFilterImageRoute.page),
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),

View file

@ -667,6 +667,22 @@ class CropImageRouteArgs {
} }
} }
/// generated route for
/// [DriftActivitiesPage]
class DriftActivitiesRoute extends PageRouteInfo<void> {
const DriftActivitiesRoute({List<PageRouteInfo>? children})
: super(DriftActivitiesRoute.name, initialChildren: children);
static const String name = 'DriftActivitiesRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftActivitiesPage();
},
);
}
/// generated route for /// generated route for
/// [DriftAlbumOptionsPage] /// [DriftAlbumOptionsPage]
class DriftAlbumOptionsRoute extends PageRouteInfo<void> { class DriftAlbumOptionsRoute extends PageRouteInfo<void> {
@ -828,6 +844,112 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [DriftCropImagePage]
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
DriftCropImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftCropImageRoute.name,
args: DriftCropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftCropImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftCropImageRouteArgs>();
return DriftCropImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftCropImageRouteArgs {
const DriftCropImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftEditImagePage]
class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
DriftEditImageRoute({
Key? key,
required BaseAsset asset,
required Image image,
required bool isEdited,
List<PageRouteInfo>? children,
}) : super(
DriftEditImageRoute.name,
args: DriftEditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
),
initialChildren: children,
);
static const String name = 'DriftEditImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftEditImageRouteArgs>();
return DriftEditImagePage(
key: args.key,
asset: args.asset,
image: args.image,
isEdited: args.isEdited,
);
},
);
}
class DriftEditImageRouteArgs {
const DriftEditImageRouteArgs({
this.key,
required this.asset,
required this.image,
required this.isEdited,
});
final Key? key;
final BaseAsset asset;
final Image image;
final bool isEdited;
@override
String toString() {
return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
}
}
/// generated route for /// generated route for
/// [DriftFavoritePage] /// [DriftFavoritePage]
class DriftFavoriteRoute extends PageRouteInfo<void> { class DriftFavoriteRoute extends PageRouteInfo<void> {
@ -844,6 +966,54 @@ class DriftFavoriteRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [DriftFilterImagePage]
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
DriftFilterImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftFilterImageRoute.name,
args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftFilterImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftFilterImageRouteArgs>();
return DriftFilterImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftFilterImageRouteArgs {
const DriftFilterImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for /// generated route for
/// [DriftLibraryPage] /// [DriftLibraryPage]
class DriftLibraryRoute extends PageRouteInfo<void> { class DriftLibraryRoute extends PageRouteInfo<void> {

View file

@ -1,3 +1,4 @@
import 'package:immich_mobile/constants/errors.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart';
@ -30,7 +31,11 @@ class ActivityService with ErrorLoggerMixin {
Future<bool> removeActivity(String id) async { Future<bool> removeActivity(String id) async {
return logError( return logError(
() async { () async {
try {
await _activityApiRepository.delete(id); await _activityApiRepository.delete(id);
} on NoResponseDtoError {
return true;
}
return true; return true;
}, },
defaultValue: false, defaultValue: false,

View file

@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@ -331,7 +332,8 @@ class BackgroundService {
Future<bool> _onAssetsChanged() async { Future<bool> _onAssetsChanged() async {
final db = await Bootstrap.initIsar(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb);
final ref = ProviderContainer(overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)]); final ref = ProviderContainer(overrides: [dbProvider.overrideWithValue(db), isarProvider.overrideWithValue(db)]);

View file

@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
@ -116,7 +117,8 @@ class BackupVerificationService {
final List<Asset> result = []; final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
final db = await Bootstrap.initIsar(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb);
await tuple.fileMediaRepository.enableBackgroundAccess(); await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService(); final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint); apiService.setEndpoint(tuple.endpoint);

View file

@ -47,14 +47,11 @@ abstract final class Bootstrap {
); );
} }
static Future<void> initDomain(Isar db, {bool shouldBufferLogs = true}) async { static Future<void> initDomain(Isar db, DriftLogger logDb, {bool shouldBufferLogs = true}) async {
// load drift dbs
final loggerDb = DriftLogger();
await StoreService.init(storeRepository: IsarStoreRepository(db)); await StoreService.init(storeRepository: IsarStoreRepository(db));
await LogService.init( await LogService.init(
logRepository: LogRepository(loggerDb), logRepository: LogRepository(logDb),
storeRepository: IsarStoreRepository(db), storeRepository: IsarStoreRepository(db),
shouldBuffer: shouldBufferLogs, shouldBuffer: shouldBufferLogs,
); );

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@ -35,7 +36,8 @@ Cancelable<T?> runInIsolateGentle<T>({
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
final db = await Bootstrap.initIsar(); final db = await Bootstrap.initIsar();
await Bootstrap.initDomain(db, shouldBufferLogs: false); final logDb = DriftLogger();
await Bootstrap.initDomain(db, logDb, shouldBufferLogs: false);
final ref = ProviderContainer( final ref = ProviderContainer(
overrides: [ overrides: [
// TODO: Remove once isar is removed // TODO: Remove once isar is removed
@ -57,6 +59,7 @@ Cancelable<T?> runInIsolateGentle<T>({
} finally { } finally {
try { try {
await LogService.I.flush(); await LogService.I.flush();
await logDb.close();
await ref.read(driftProvider).close(); await ref.read(driftProvider).close();
// Close Isar safely // Close Isar safely

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