chore: pull main

This commit is contained in:
shalong-tanwen 2023-10-22 21:05:50 +05:30
commit 16646d9946
152 changed files with 4494 additions and 617 deletions

View file

@ -21,7 +21,7 @@ jobs:
submodules: "recursive"
- name: Run e2e tests
run: docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
doc-tests:
name: Run documentation checks

View file

@ -4,6 +4,9 @@ dev:
dev-new:
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-new-update:
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
@ -20,7 +23,7 @@ pull-stage:
docker-compose -f ./docker/docker-compose.staging.yml pull
test-e2e:
docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
prod:
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@ -32,4 +35,4 @@ api:
cd ./server && npm run api:generate
attach-server:
docker exec -it docker_immich-server_1 sh
docker exec -it docker_immich-server_1 sh

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Disclaimer

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Avís legal

View file

@ -24,6 +24,7 @@
<a href="README_ca_ES.md">Català</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Descargo de responsabilidad

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Clause de non-responsabilité

113
README_it_IT.md Normal file
View file

@ -0,0 +1,113 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Declino di responsabilità
- ⚠️ Il progetto è in fase di sviluppo **molto avanzato**.
- ⚠️ Possibilità di bug e cambiamenti rilevanti.
- ⚠️ **Non utilizzare l'app come unico salvataggio delle tue foto e dei tuoi video.**
- ⚠️ Utilizza sempre una tecnica [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) di backup per le foto e i video a cui tieni!
## Contenuto
- [Documentazione Ufficiale](https://immich.app/docs)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Funzionalità](#features)
- [Introduzione](https://immich.app/docs/overview/introduction)
- [Installazione](https://immich.app/docs/install/requirements)
- [Linee Guida per Contribuire](https://immich.app/docs/overview/support-the-project)
- [Supporta il Progetto](#support-the-project)
## Documentazione
La documentazione ufficiale, inclusa la guida all'installazione, è disponibile qui: https://immich.app/.
## Demo
Prova la demo del progetto https://demo.immich.app
Sull'app mobile, imposta `https://demo.immich.app/api` come `Server Endpoint URL`
```bash title="Demo Credential"
Credenziali di accesso
email: demo@immich.app
password: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
# Funzionalità
| Funzionalità | Mobile | Web |
| ---------------------------------------------- | ------ | --- |
| Caricamento e visualizzazione di foto e video | Sì | Sì |
| Backup automatico quando l'app è in esecuzione | Sì | N/A |
| Selezione degli album per backup | Sì | N/A |
| Download foto e video sul dispositivo | Sì | Sì |
| Supporto multi utente | Sì | Sì |
| Album e album condivisi | Sì | Sì |
| Barra di scorrimento con trascinamento | Sì | Sì |
| Supporto formati raw | Sì | Sì |
| Visualizzazione metadata (EXIF, map) | Sì | Sì |
| Ricerca per metadata, oggetti, volti e CLIP | Sì | Sì |
| Funzioni di amministrazione degli utenti | No | Sì |
| Backup in background | Sì | N/A |
| Scroll virtuale | Sì | Sì |
| Supporto OAuth | Sì | Sì |
| API Keys | N/A | Sì |
| Backup e riproduzione di LivePhoto | iOS | Sì |
| Archiviazione impostata dall'utente | Sì | Sì |
| Condivisione pubblica | No | Sì |
| Archivio e Preferiti | Sì | Sì |
| Mappa globale | Sì | Sì |
| Collaborazione con utenti | Sì | Sì |
| Riconoscimento facciale e categorizzazione | Sì | Sì |
| Ricordi (x anni fa) | Sì | Sì |
| Supporto offline | Sì | No |
| Galleria sola lettura | Sì | Sì |
# Supporta il progetto
Mi dedico al progetto e non smetterò di farlo. Manterrò aggiornata la documentazione, aggiungerò nuove funzioni e risolverò i bug, ma non posso farlo da solo. Ho bisogno del tuo aiuto che mi da motivazione per continuare.
Come detto dal nostro host [selfhosted.show - Nell'episodio 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418), quello che il team ed io stiamo facendo è un lavoro enorme. Mi piacerebbe dedicarmi al progetto full-time e chiedo il tuo aiuto affinchè sia possibile.
Se pensi che Immich sia una buona causa e che l'app sia qualcosa che useresti nel lungo termine, sappi che puoi supportare il progetto scegliendo tra le opzioni sotto elencate.
## Donazioni
- [Donazione mensile](https://github.com/sponsors/alextran1502) tramite GitHub Sponsors
- [Donazione una tantum](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) tramite GitHub Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

View file

@ -24,6 +24,7 @@
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## 免責事項

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Disclaimer

View file

@ -25,6 +25,7 @@
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Feragatname

View file

@ -29,6 +29,7 @@
<a href="README_fr_FR.md">Français</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.82.0
* The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'removeParent'?: boolean;
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'stackParentId'?: string;
}
/**
*
@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'smartInfo'?: SmartInfoResponseDto;
/**
*
* @type {Array<AssetResponseDto>}
* @memberof AssetResponseDto
*/
'stack'?: Array<AssetResponseDto>;
/**
*
* @type {number}
* @memberof AssetResponseDto
*/
'stackCount': number;
/**
*
* @type {string}
* @memberof AssetResponseDto
*/
'stackParentId'?: string | null;
/**
*
* @type {Array<TagResponseDto>}
@ -3053,6 +3083,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'allowUpload'?: boolean;
/**
* Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
* @type {boolean}
* @memberof SharedLinkEditDto
*/
'changeExpiryTime'?: boolean;
/**
*
* @type {string}
@ -3981,6 +4017,25 @@ export interface UpdateLibraryDto {
*/
'name'?: string;
}
/**
*
* @export
* @interface UpdateStackParentDto
*/
export interface UpdateStackParentDto {
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'newParentId': string;
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'oldParentId': string;
}
/**
*
* @export
@ -7135,6 +7190,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'updateStackParentDto' is not null or undefined
assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
const localVarPath = `/asset/stack/parent`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {File} assetData
@ -7601,6 +7700,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -7892,6 +8001,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -8499,6 +8617,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto
}
/**
* Request parameters for updateStackParent operation in AssetApi.
* @export
* @interface AssetApiUpdateStackParentRequest
*/
export interface AssetApiUpdateStackParentRequest {
/**
*
* @type {UpdateStackParentDto}
* @memberof AssetApiUpdateStackParent
*/
readonly updateStackParentDto: UpdateStackParentDto
}
/**
* Request parameters for uploadFile operation in AssetApi.
* @export
@ -8939,6 +9071,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.82.0
* The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.82.0
* The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.82.0
* The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.82.0
* The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -21,6 +21,10 @@ services:
- .env
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on:
- redis
- database
@ -48,6 +52,10 @@ services:
- 9231:9230
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on:
- database
- immich-server
@ -73,6 +81,10 @@ services:
volumes:
- ../web:/usr/src/app
- /usr/src/app/node_modules
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
- immich-server

View file

@ -1,10 +1,10 @@
version: "3.8"
# Compose file for dockerized end-to-end testing of the backend
name: "immich-test-e2e"
services:
immich-server-test:
image: immich-server-test
immich-server:
image: immich-server-dev:latest
build:
context: ../server
dockerfile: Dockerfile
@ -14,27 +14,20 @@ services:
- ../server:/usr/src/app
- /usr/src/app/node_modules
environment:
- DB_HOSTNAME=immich-database-test
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=e2e_test
- IMMICH_RUN_ALL_TESTS=true
depends_on:
- immich-database-test
networks:
- immich-test-network
- database
immich-database-test:
container_name: immich-database-test
database:
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
command: -c fsync=off
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: e2e_test
networks:
- immich-test-network
logging:
driver: none
networks:
immich-test-network:

View file

@ -16,10 +16,6 @@ sidebar_position: 7
Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich.
### Why doesn't Immich watch an existing photo gallery directory?
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### Why are only photos and not videos being uploaded to Immich?
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes. Also check the disk space of your reverse proxy, in some cases proxies caches requests to disk before passing them on, and if disk space runs out the request fails.

View file

@ -11,6 +11,6 @@ Running into an issue or have a question? Try the following:
3. Search through existing [GitHub Issues][github-issues].
4. Open a help ticket on [Discord][discord-link].
[github-issues]: https://github.com/immich-app/immich/releases
[github-issues]: https://github.com/immich-app/immich/issues
[github-releases]: https://github.com/immich-app/immich/releases
[discord-link]: https://discord.com/invite/D8JsnBEuKb

View file

@ -4,7 +4,7 @@ sidebar_position: 1
# Introduction
<img src={require('./img/feature-panel.png').default} alt="Immich" />
<img src={require('./img/feature-panel.png').default} alt="Immich - Self-hosted photos and videos backup tool" />
## Welcome!

View file

@ -6,28 +6,31 @@ function HomepageHeader() {
return (
<header>
<section className="text-center m-6 p-12 border border-red-400 rounded-[50px] bg-gray-100 dark:bg-immich-dark-gray">
<h1 className="md:text-6xl font-bold mb-10 font-immich-title text-immich-primary dark:text-immich-dark-primary">
IMMICH
<img src="img/immich-logo.svg" className="md:h-24 h-12 mb-2" alt="Immich logo" />
<h1 className="md:text-6xl font-immich-title mb-10 text-immich-primary dark:text-immich-dark-primary uppercase">
Immich
</h1>
<div className="font-thin sm:text-base md:text-2xl my-12 sm:leading-tight">
<p>SELF-HOSTED BACKUP SOLUTION </p>
<p>FOR PHOTOS AND VIDEOS</p>
<p>ON MOBILE DEVICE</p>
<p className="mb-1 uppercase">
Self-hosted backup solution <span className="block"></span>
for photos and videos <span className="block"></span>
on mobile device
</p>
</div>
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 ">
<Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold"
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold uppercase"
to="docs/overview/introduction"
>
GET STARTED
Get started
</Link>
<Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-full hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold"
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-full hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
to="https://demo.immich.app/"
>
DEMO PORTAL
Demo portal
</Link>
</div>

98
docs/static/img/immich-logo.svg vendored Normal file
View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5"
style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;}
.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;}
.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;}
.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;}
.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;}
.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;}
</style>
<path class="st0" d="M210.5,549.6c-2.2-0.2-5.5-1-9.7-2.2c-52.4-15.7-99-46.5-133.8-88.5c-8.8-10.7-17.2-22.4-19.4-27.5
c-8.1-18.1-6.3-38.7,4.8-55.4c5-7.5,13.2-15,20.5-18.7c1.2-0.6,54.1-20,55.8-20.4c0.5-0.1,0.5,0.2-0.3,2.1c-0.7,1.7-1,3.1-1.1,5.5
l-0.1,3.2l2.8,5.8c8.7,17.9,19.2,32.7,33.2,46.4c6.3,6.2,7.8,7.6,13.8,12.3c22.7,18.1,52,30.7,79.9,34.3c2.5,0.3,5,0.8,5.7,1
c2.8,0.9,7.7-0.8,11-3.7l1.8-1.6l-0.2,4.8c-0.1,2.7-0.6,15.4-1,28.3c-0.6,20.3-0.8,24-1.5,27.5c-3.9,20.7-18.6,37.5-38.4,44.1
c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/>
<path class="st1" d="M339.8,549.4c-4-0.4-9.4-1.6-13.2-2.9c-3.4-1.2-10-4.4-12.5-6.1c-10.9-7.4-19-17.9-23.1-30
c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8
c37.7-5.3,71.2-22.2,97.4-49.1c12.2-12.5,21.4-25.5,29.9-42.4l3.5-7l0-3.6c0-3.1-0.1-3.8-1-5.7c-0.5-1.2-0.9-2.1-0.9-2.2
c0.2-0.2,55.3,20.1,56.9,20.9c2.6,1.3,6.6,4.1,9.9,7c9.2,7.7,16.1,19.4,18.8,31.8c0.7,3.1,0.8,4.8,0.8,11.3c0,8.6-0.5,11.7-2.9,18.7
c-1.7,5-2.9,7.2-7.1,13.1c-7.6,11-15.3,20.5-25.2,31.2c-32.8,35.4-76.5,62.5-123.4,76.3C351.6,549.6,347.2,550.1,339.8,549.4z"/>
<path class="st2" d="M255.6,438c-25.9-4.2-50.7-14.9-71.7-31c-5.2-4-8.7-7.1-14.1-12.4c-12.7-12.5-21.9-24.9-30.5-41.4
c-2.3-4.4-2.4-4.7-2.4-7.1c0-8.8,8.5-15.2,16.9-12.7c5.6,1.7,9.6,6.8,9.7,12.2c0,2.6-0.8,4.6-2.6,6.2c-1.2,1.1-3.2,1.9-4.6,1.9
c-1.2,0-3.3-0.8-4.3-1.6c-2.1-1.8-2-1,0.4,3.2c19.3,33.8,52.3,59.1,90,69.1c5.7,1.5,11.5,2.7,11.8,2.4c0.1-0.1-0.4-0.8-1.3-1.6
c-5.1-4.5-2.3-11.7,5-12.8c5.4-0.8,11.4,2.7,13.9,8c0.8,1.7,1,2.5,1,5.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8
C260.6,438.7,257.9,438.3,255.6,438z"/>
<path class="st0" d="M297.6,438.2c-3.4-1.3-6.4-4.3-7.8-8.1c-1.1-2.9-0.9-7.3,0.5-10.2c2.6-5.3,8.7-8.5,14.4-7.5
c2.9,0.5,4.7,1.9,6,4.3c0.8,1.6,1,2.2,0.8,3.6c-0.3,2.2-0.9,3.3-2.7,4.8c-0.8,0.7-1.4,1.4-1.3,1.5c0.5,0.5,13.4-2.7,21.3-5.4
c33.6-11.3,62.5-35.1,80.4-66.1c2.5-4.4,2.6-5,0.5-3.2c-2.8,2.4-7,1.9-9.6-1c-4-4.6-0.7-13.8,6.1-16.9c2-0.9,2.7-1,5.5-1
c2.9,0,3.5,0.1,5.6,1.1c4.4,2.1,7.4,6.4,7.8,11c0.2,2.2,0.1,2.3-2.2,6.9c-23,45.9-67,78.1-117.2,85.9
C300.2,438.8,299.4,438.9,297.6,438.2z"/>
<path class="st1" d="M211.1,398.5c-4.7-0.9-8.7-2.7-12.9-5.9c-10.8-8.1-13.5-22.3-6.6-33.7c0.7-1.2,1.1-2.2,1-2.4
c-0.2-0.2-1.2-0.6-2.3-1.1c-7.6-3-13-10.6-13.5-19.1c-0.5-7.4,3.1-15,9-19.4c1-0.7,2.2-1.5,2.6-1.8c0.8-0.4,68.9-22.7,69.4-22.7
c0.2,0,0.7,0.7,1.2,1.5c0.5,0.8,1.6,2.3,2.4,3.3c1.2,1.4,1.5,1.9,1.2,2.3c-0.2,0.3-6.9,9.5-14.8,20.5
c-15.9,21.9-15.5,21.3-13.4,23.4c1.3,1.3,2.9,1.4,4.4,0.3c0.6-0.4,7.5-9.7,15.5-20.7c11.2-15.4,14.6-19.9,15-19.7
c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,39.7,0,38.8-2.5,44c-2.6,5.3-7.2,9.3-12.7,11.2c-3.7,1.3-6.8,1.6-10.2,1
c-5.5-0.9-9.8-3.2-13.7-7.4l-2.2-2.4l-0.6,0.9c-3,4.3-8.6,8.1-14,9.5C218.2,398.6,213.2,398.9,211.1,398.5z"/>
<path class="st3" d="M342.9,398.5c-5.5-0.9-9.9-3.2-14.3-7.6l-3.2-3.2l-0.7,1c-2.3,3.3-6.8,6.5-11.1,7.9c-3.7,1.2-9.2,1.4-12.6,0.3
c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.8-0.4c1-0.2,2.7-0.8,3.9-1.2c1.1-0.5,2.1-0.8,2.2-0.7c0.1,0.1,6.5,9,14.4,19.9
c7.8,10.9,14.7,20.1,15.2,20.5c2.2,1.9,5.4,0.4,5.4-2.6c0-1.4-1-2.9-13.8-20.5c-7.6-10.5-14.2-19.6-14.7-20.4l-0.9-1.3l1.4-1.7
c0.8-0.9,1.9-2.5,2.5-3.4l1-1.6l34.4,11.2c18.9,6.2,35.1,11.6,35.9,12.1c6.8,4,11.1,11.3,11.1,19.1c0,4.1-0.5,6.4-2.4,10.2
c-2,4.1-5.5,7.6-9.6,9.7c-1.6,0.8-3.2,1.5-3.4,1.5c-1,0-0.9,0.7,0.3,2.6c2.8,4.3,4,8.5,3.9,13.7c0,8.1-3.7,15.2-10.6,20.3
C356.4,397.6,349.5,399.5,342.9,398.5z"/>
<path class="st2" d="M53.9,341.9c-0.5-0.1-2.3-0.4-3.9-0.7c-15.6-2.6-30.4-12.6-38.8-26.2c-3.5-5.7-6.4-13.2-7.8-19.9
c-1.2-6.1-0.8-28.1,0.8-43.1c4.5-43,19-84.3,42.2-120.7c6.5-10.2,14.9-21.5,18.2-24.6c17.8-16.6,43.1-20.5,64.8-10
c4.3,2.1,8.8,5.1,12.7,8.6c2.8,2.4,5.8,6.1,20.9,25.5c9.7,12.5,17.8,22.8,17.9,23c0.2,0.2-0.9,0.4-3.2,0.4c-2.5,0-4.1,0.2-5.7,0.7
c-2.1,0.7-2.6,1.1-7.9,6.3c-8.2,8.1-14.4,15.3-20.3,23.9c-15.5,22.2-25.4,47.7-28.8,74.8c-2.2,16.9-1.6,37.5,1.6,52.3
c0.3,1.4,0.5,2.8,0.4,3c-0.1,0.2,0.2,1.3,0.8,2.4c1.1,2.4,4.3,5.7,6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2-13.1,3.8-27.6,8
c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/>
<path class="st3" d="M494.7,341.7c-2.1-0.3-33.8-9.1-56.5-15.8l-2.5-0.7l1.6-0.8c3.4-1.7,7.2-6.6,7.3-9.6c0-0.7,0.4-3.3,0.8-5.8
c3.9-22.7,3.1-46.1-2.5-68.4c-6.4-25.5-18.6-49.2-35.8-69.1c-4.6-5.3-14.8-15.4-16.4-16.1c-2.4-1.1-5.1-1.6-8-1.4l-2.7,0.2l1.2-1.5
c0.7-0.8,8.5-10.8,17.5-22.3c8.9-11.5,17.2-21.8,18.5-23.1c2.6-2.7,7-6.2,10.3-8.2c19.3-11.6,43-11.1,61.6,1.2
c5.4,3.6,8.2,6.2,12.3,11.7c26.4,34.5,44,73.7,52.3,116.2c3.4,17.6,4.9,33.3,5,52.4c0,13-0.2,14.8-2.5,21.8
C547.8,328.6,521.7,345.2,494.7,341.7z"/>
<path class="st4" d="M133.9,318.5c-2-0.5-4.6-1.9-6-3.3c-2.5-2.4-3.1-3.5-3.7-7.3c-4.4-27.3-2.2-54,6.7-79.3
c5.3-15.1,13.5-30.5,23-43.1c5.8-7.8,16.6-19.5,19-20.7c4.7-2.4,11.3-1.2,15.2,2.7c5.4,5.4,5.2,13.9-0.3,19.1
c-4.3,4-9.4,4.4-12.6,0.9c-1.7-1.9-2.2-3.9-1.7-6.4c0.2-1.1,0.3-2,0.2-2.2c-0.3-0.3-3.6,3.3-8.3,9.1c-17.6,21.8-28.5,48-31.9,76.5
c-1.1,9.3-1,26.4,0.1,34.6c0.3,1.8,0.8,1.9,1.4,0.1c0.9-2.6,4-4.7,6.8-4.7c3,0,5.9,2.2,7.5,5.7c0.6,1.3,0.8,2.3,0.8,5.2
c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/>
<path class="st1" d="M422.6,318.5c-3.7-0.6-7.7-3.6-9.4-7.1c-3.8-7.5,0.1-16.9,6.9-16.9c3.1,0,5.8,2,6.9,5.2
c0.4,1.2,0.5,1.3,0.7,0.7c1.3-3.7,1.7-26.4,0.6-35.7c-3.6-29.6-14.5-55.3-33-77.9c-5.5-6.7-8.4-9.4-7.1-6.6c0.7,1.4,0.5,4.3-0.3,5.9
c-0.9,1.7-3.2,3.5-5,3.8c-3.2,0.6-7.9-1.6-10.2-4.8c-6.5-8.8-0.5-21.2,10.4-21.4c4.6-0.1,5.2,0.3,11.2,6.4
c12.1,12.3,21.1,24.9,28.8,40.3c13.2,26.3,18.6,54.9,16.1,84.5c-0.5,5.6-2,15.7-2.6,17.1c-1.3,2.8-4.8,5.5-8.4,6.5
C425.9,318.9,425.1,318.9,422.6,318.5z"/>
<path class="st0" d="M178.2,307.2c-6-1.3-12.2-6.2-14.9-11.7c-3.4-7-3.1-15.1,0.9-21.6c0.7-1.2,1.2-2.3,1.1-2.4
c-0.1-0.1-1.1-0.6-2.1-1c-3.9-1.5-8.1-4.8-10.7-8.3c-4.6-6.2-6.1-14.6-3.9-22.1c2.9-10.3,9.4-16.8,19.1-19.3c2.8-0.7,9-0.8,11.7,0
c1.1,0.3,2.2,0.5,2.4,0.5c0.2,0,0.3-0.7,0.3-1.5c0-2.9,0.8-5.8,2.4-9.2c5.2-10.8,18.1-15.5,29-10.5c2.7,1.2,6.2,3.8,7.8,5.8
c0.7,0.8,10.3,14,21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1-1.9,2.6-2.5,3.5c-0.6,1-1.2,1.7-1.5,1.6c-4.5-1.7-46.7-15-47.7-15
c-1.9,0-3.1,1.3-3.1,3.2c0,1,0.2,1.7,0.8,2.3c0.6,0.6,7.8,3.1,24.5,8.5l23.7,7.7l-0.1,4.3l-0.1,4.3L223,295.9
c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/>
<path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5
c0.7-0.6,0.9-1.1,0.9-2.2c0-2-1.2-3.3-3.1-3.3c-0.9,0-10.5,2.9-24.7,7.5c-12.8,4.1-23.4,7.5-23.6,7.5c-0.1,0-0.7-0.8-1.3-1.9
c-0.6-1-1.6-2.5-2.2-3.2c-0.7-0.7-1.2-1.5-1.2-1.6c0-0.2,9.6-13.5,21.4-29.6c18.9-26,21.6-29.6,23.6-31.1c5.7-4.4,13.1-5.8,19.7-3.9
c9,2.7,16.1,11.6,16.1,20.3c0,2.3-0.1,2.3,3.1,1.5c4.7-1.1,11.5-0.5,16,1.5c4.6,2,9,6,11.5,10.2c2.1,3.6,3.9,9.4,4.2,13.2
c0.3,5.2-1.1,10.7-4,15.3c-2.6,4.1-7.8,8.3-12.1,9.8c-0.9,0.3-1.7,0.8-1.7,1c0,0.2,0.4,1,0.9,1.7c2.4,3.6,3.6,7.7,3.5,12.7
c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/>
<path class="st5" d="M276.2,298.9c-6.1-1.6-11.4-6.8-13.2-12.9c-0.7-2.4-0.7-7.5,0-9.9c1.7-5.8,6.6-10.8,12.3-12.5
c2.7-0.8,7.2-0.9,10-0.2c6.2,1.6,11.6,7.1,13.2,13.3c1.6,6-0.3,12.6-5,17.3C288.9,298.6,282.2,300.5,276.2,298.9z"/>
<path class="st2" d="M248.3,229.8c-13.3-18.3-21.2-29.6-22-31.1c-1.4-3-1.9-5.5-1.9-9.4c0-14.1,13.1-24.4,27.1-21.4
c1.4,0.3,2.6,0.5,2.7,0.5s0.3-1.3,0.4-2.8c0.8-10.7,8.4-19.6,18.9-22.4c3.9-1,10.6-1,14.5,0c8.9,2.3,15.9,9.3,18.2,18.2
c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.5-0.3,3-0.6c7.4-1.6,15.2,0.7,20.5,6c4.3,4.3,6.6,9.6,6.6,15.6
c0,4-0.6,6.5-2.4,10c-0.6,1.2-10.4,15-21.7,30.7c-17.8,24.5-20.8,28.5-21.4,28.3c-0.4-0.1-1.9-0.6-3.4-1.1c-1.5-0.5-2.9-0.9-3.3-0.9
c-0.7,0-0.7-0.8-0.3-25.5v-25.5l-1.4-0.9c-1-1.1-2.5-1.5-3.8-0.9c-2,0.8-2-0.5-1.8,27.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9
s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/>
<path class="st3" d="M210.9,164.8c-4.1-0.9-7.7-3.6-9.6-7.4c-1.4-2.8-1.7-7.3-0.5-10.3c1.7-4.5,3.9-6.1,15.6-11.2
c15.8-7,31.4-11.1,49.2-12.9c7.3-0.8,23.2-0.8,30.6,0c17.4,1.8,33.3,6,49.1,13c7.3,3.2,12.5,6.1,13.6,7.5c4.3,5.6,3.8,12.7-1.1,17.6
c-5.1,5.1-12.9,5.4-18.1,0.7c-2-1.8-3-3.5-3.4-5.6c-0.7-4,2.9-8.1,7.3-8.2c1.4,0,1.5-0.1,1.1-0.5c-0.3-0.3-2.2-1.2-4.3-2.1
c-33.2-14.5-70.5-16.4-105-5.4c-7.5,2.4-19,7.2-18.6,7.7c0.1,0.2,0.8,0.3,1.6,0.3c5.6,0,9.1,6.2,6.1,10.8
C221.6,163.3,215.9,165.9,210.9,164.8z"/>
<path class="st4" d="M174.7,123.4c-8.9-13.1-16.8-25.1-17.5-26.6c-1.6-3.3-3.6-9.2-4.4-13c-2.6-12.5-0.9-25.8,5-37.5
c4.2-8.3,11.2-16.3,18.6-21.3c5-3.4,6.1-3.9,12.8-6.3c23.1-8.2,47.2-13.1,73.4-15c7.5-0.6,28.5-0.6,36.3,0
c25.5,1.8,50.6,6.9,73,14.8c6.4,2.2,8.2,3.1,13.1,6.5c9.8,6.6,18.1,17.5,22,29.2c2.2,6.5,2.7,10,2.7,17.9c0,7.9-0.5,11.3-2.7,17.9
c-2.3,6.8-3.7,9.1-20.3,33.6l-16.1,23.8l-0.4-2.2c-0.2-1.2-0.9-3-1.4-4c-1-1.8-4.4-5.6-4.7-5.2c-0.1,0.1-1.2-0.4-2.4-1.1
c-9.1-5.2-21.9-10.5-33.2-13.9c-37-11-77.2-8.8-113,6.1c-4.9,2.1-17.7,8.4-19.2,9.5c-2.2,1.6-5.1,6.8-5.1,9c0,0.4-0.1,1-0.3,1.2
C191,147,184.7,138,174.7,123.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.82.0"
version = "1.82.1"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View file

@ -36,7 +36,7 @@ platform :android do
build_type: 'Release',
properties: {
"android.injected.version.code" => 106,
"android.injected.version.name" => "1.82.0",
"android.injected.version.name" => "1.82.1",
}
)
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')

View file

@ -131,7 +131,10 @@
"control_bottom_app_bar_delete_from_local": "Delete from device",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_share_to": "Share To",
"control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_unarchive": "Unarchive",
"control_bottom_app_bar_upload": "Upload",
"create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create",
"create_shared_album_page_share": "Share",
@ -167,6 +170,7 @@
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_share_error": "Share Error",
"image_viewer_page_state_provider_download_success": "Download Success",
"library_page_albums": "Albums",
"library_page_archive": "Archive",
@ -277,6 +281,7 @@
"setting_pages_app_bar_settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting",
"share_add": "Add",
"share_done": "Done",
"share_add_photos": "Add photos",
"share_add_title": "Add a title",
"share_create_album": "Create album",
@ -286,6 +291,7 @@
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
"sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "Create shared album",
"sharing_silver_appbar_shared_links": "Shared links",
"sharing_silver_appbar_share_partner": "Share with partner",
"tab_controller_nav_library": "Library",
"tab_controller_nav_photos": "Photos",
@ -339,5 +345,24 @@
"trash_page_select_assets_btn": "Select assets",
"trash_page_empty_trash_btn": "Empty trash",
"trash_page_empty_trash_dialog_ok": "Ok",
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_remove_from_stack": "Remove from Stack",
"viewer_unstack": "Un-Stack",
"delete_shared_link_dialog_title": "Delete Shared Link",
"delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?",
"shared_link_create_app_bar_title": "Create link to share",
"shared_link_edit_app_bar_title": "Edit link",
"shared_link_create_info": "Let anyone with the link see the selected photo(s)",
"shared_link_edit_description": "Description",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_show_meta": "Show metadata",
"shared_link_edit_allow_download": "Allow public user to download",
"shared_link_edit_allow_upload": "Allow public user to upload",
"shared_link_edit_change_expiry": "Change expiration time",
"shared_link_edit_submit_button": "Update link",
"shared_link_create_submit_button": "Create link",
"shared_link_app_bar_title": "Shared Links",
"shared_link_manage_links": "Manage Shared links",
"shared_link_empty": "You don't have any shared links"
}

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.82.0"
version_number: "1.82.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -210,6 +210,18 @@ class AlbumViewerAppbar extends HookConsumerWidget
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
AutoRouter.of(context)
.push(SharedLinkEditRoute(albumId: album.remoteId));
Navigator.pop(context);
},
title: const Text(
"control_bottom_app_bar_share",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () =>

View file

@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: albumInfo.assets,
isNewAlbum: false,
canDeselect: false,
query: getRemoteAssetQuery(ref),
),
);

View file

@ -4,26 +4,27 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({
Key? key,
required this.existingAssets,
this.isNewAlbum = false,
this.canDeselect = false,
required this.query,
}) : super(key: key);
final Set<Asset> existingAssets;
final bool isNewAlbum;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
final bool canDeselect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(currentUserProvider);
final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
final renderList = ref.watch(renderListQueryProvider(query));
final selected = useState<Set<Asset>>(existingAssets);
final selectionEnabledHook = useState(true);
@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
selected.value = assets;
},
selectionActive: true,
preselectedAssets: isNewAlbum ? selected.value : existingAssets,
canDeselect: isNewAlbum,
preselectedAssets: existingAssets,
canDeselect: canDeselect,
showMultiSelectIndicator: false,
);
}
@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
),
centerTitle: false,
actions: [
if (selected.value.isNotEmpty)
if (selected.value.isNotEmpty || canDeselect)
TextButton(
onPressed: () {
var payload =
@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
.popForced<AssetSelectionPageResult>(payload);
},
child: Text(
"share_add",
canDeselect ? "share_done" : "share_add",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,

View file

@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
// ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget {
@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>(
initialAssets != null ? Set.from(initialAssets!) : const {},);
initialAssets != null ? Set.from(initialAssets!) : const {},
);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
showSelectUserPage() async {
@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: selectedAssets.value,
isNewAlbum: true,
canDeselect: true,
query: getRemoteAssetQuery(ref),
),
);
if (selectedAsset == null) {

View file

@ -147,13 +147,13 @@ class SharingPage extends HookConsumerWidget {
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
AutoRouter.of(context).push(const PartnerRoute()),
AutoRouter.of(context).push(const SharedLinkRoute()),
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
Icons.link,
size: 20,
),
label: const Text(
"sharing_silver_appbar_share_partner",
"sharing_silver_appbar_shared_links",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
@ -179,6 +179,17 @@ class SharingPage extends HookConsumerWidget {
fontSize: 22,
),
),
actions: [
IconButton(
splashRadius: 25,
iconSize: 20,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
size: 20,
),
onPressed: () => AutoRouter.of(context).push(const PartnerRoute()),
),
],
);
}

View file

@ -0,0 +1,50 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> {
final Asset _asset;
final Ref _ref;
AssetStackNotifier(
this._asset,
this._ref,
) : super([]) {
fetchStackChildren();
}
void fetchStackChildren() async {
if (mounted) {
state = await _ref.read(assetStackProvider(_asset).future);
}
}
removeChild(int index) {
if (index < state.length) {
state.removeAt(index);
}
}
}
final assetStackStateProvider = StateNotifierProvider.autoDispose
.family<AssetStackNotifier, List<Asset>, Asset>(
(ref, asset) => AssetStackNotifier(asset, ref),
);
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
// Guard [local asset]
if (asset.remoteId == null) {
return [];
}
return await ref
.watch(dbProvider)
.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdEqualTo(asset.remoteId)
.findAll();
});

View file

@ -57,9 +57,19 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService
.shareAsset(asset)
.then((_) => Navigator.of(buildContext).pop());
_shareService.shareAsset(asset).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
Navigator.of(buildContext).pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,

View file

@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
final renderListProvider =
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
@ -13,3 +14,19 @@ final renderListProvider =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
);
});
final renderListQueryProvider = StreamProvider.family<RenderList,
QueryBuilder<Asset, Asset, QAfterSortBy>?>(
(ref, query) async* {
if (query == null) {
return;
}
final settings = ref.watch(appSettingsServiceProvider);
final groupBy = GroupAssetsBy
.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
},
);

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
class AssetStackService {
AssetStackService(this._api);
final ApiService _api;
updateStack(
Asset parentAsset, {
List<Asset>? childrenToAdd,
List<Asset>? childrenToRemove,
}) async {
// Guard [local asset]
if (parentAsset.remoteId == null) {
return;
}
try {
if (childrenToAdd != null) {
final toAdd = childrenToAdd
.where((e) => e.isRemote)
.map((e) => e.remoteId!)
.toList();
await _api.assetApi.updateAssets(
AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId),
);
}
if (childrenToRemove != null) {
final toRemove = childrenToRemove
.where((e) => e.isRemote)
.map((e) => e.remoteId!)
.toList();
await _api.assetApi.updateAssets(
AssetBulkUpdateDto(ids: toRemove, removeParent: true),
);
}
} catch (error) {
debugPrint("Error while updating stack children: ${error.toString()}");
}
}
updateStackParent(Asset oldParent, Asset newParent) async {
// Guard [local asset]
if (oldParent.remoteId == null || newParent.remoteId == null) {
return;
}
try {
await _api.assetApi.updateStackParent(
UpdateStackParentDto(
oldParentId: oldParent.remoteId!,
newParentId: newParent.remoteId!,
),
);
} catch (error) {
debugPrint("Error while updating stack parent: ${error.toString()}");
}
}
}
final assetStackServiceProvider = Provider(
(ref) => AssetStackService(
ref.watch(apiServiceProvider),
),
);

View file

@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
@ -14,6 +15,7 @@ final imageViewerServiceProvider =
class ImageViewerService {
final ApiService _apiService;
final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService);
@ -29,6 +31,16 @@ class ImageViewerService {
asset.livePhotoVideoId!,
);
if (imageResponse.statusCode != 200 ||
motionReponse.statusCode != 200) {
final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
_log.severe(
"Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}",
);
return false;
}
final AssetEntity? entity;
final tempDir = await getTemporaryDirectory();
@ -48,6 +60,13 @@ class ImageViewerService {
var res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) {
_log.severe(
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
);
return false;
}
final AssetEntity? entity;
if (asset.isImage) {

View file

@ -8,11 +8,13 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final int totalAssets;
final int initialIndex;
final int heroOffset;
final bool showStack;
GalleryViewerPage({
super.key,
@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.loadAsset,
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final isFromTrash = isTrashEnabled &&
navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
Asset asset() => currentAsset;
Asset asset() => stackIndex.value == -1
? currentAsset
: stackElements.elementAt(stackIndex.value);
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
useEffect(
() {
@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ExifBottomSheet(asset: currentAsset),
child: ExifBottomSheet(asset: asset()),
);
},
);
}
void removeAssetFromStack() {
if (stackIndex.value > 0 && showStack) {
ref
.read(assetStackStateProvider(currentAsset).notifier)
.removeChild(stackIndex.value - 1);
stackIndex.value = stackIndex.value - 1;
}
}
void handleDelete(Asset deleteAsset) async {
Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
{deleteAsset},
force: force,
);
if (isDeleted) {
if (isDeleted && isParent) {
if (totalAssets == 1) {
// Handle only one asset
AutoRouter.of(context).pop();
@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget {
// Asset is trashed
if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false);
// Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && isDeleted && deleteAsset.isRemote) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'Asset trashed',
gravity: ToastGravity.BOTTOM,
);
if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && deleteAsset.isRemote && isParent) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'Asset trashed',
gravity: ToastGravity.BOTTOM,
);
}
removeAssetFromStack();
}
return;
}
@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
showDialog(
context: context,
builder: (BuildContext _) {
return DeleteDialog(onDelete: () => onDelete(true));
return DeleteDialog(
onDelete: () async {
final isDeleted = await onDelete(true);
if (isDeleted) {
removeAssetFromStack();
}
},
);
},
);
}
@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget {
ref
.watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived);
AutoRouter.of(context).pop();
if (isParent) {
AutoRouter.of(context).pop();
return;
}
removeAssetFromStack();
}
handleUpload(Asset asset) {
@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
buildBottomBar() {
Widget buildStackedChildren() {
return ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: stackElements.length,
itemBuilder: (context, index) {
final assetId = stackElements.elementAt(index).remoteId;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => stackIndex.value = index,
child: Container(
width: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: index == stackIndex.value
? Border.all(
color: Colors.white,
width: 2,
)
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl:
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
httpHeaders: {
"Authorization":
"Bearer ${Store.get(StoreKey.accessToken)}",
},
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
),
),
),
);
},
);
}
void showStackActionItems() {
showModalBottomSheet<void>(
context: context,
enableDrag: false,
builder: (BuildContext ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isParent)
ListTile(
leading: const Icon(
Icons.bookmark_border_outlined,
size: 24,
),
onTap: () async {
await ref
.read(assetStackServiceProvider)
.updateStackParent(
currentAsset,
stackElements.elementAt(stackIndex.value),
);
Navigator.pop(ctx);
AutoRouter.of(context).pop();
},
title: const Text(
"viewer_stack_use_as_main_asset",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(
Icons.copy_all_outlined,
size: 24,
),
onTap: () async {
if (isParent) {
await ref
.read(assetStackServiceProvider)
.updateStackParent(
currentAsset,
stackElements
.elementAt(1), // Next asset as parent
);
// Remove itself from stack
await ref.read(assetStackServiceProvider).updateStack(
stackElements.elementAt(1),
childrenToRemove: [currentAsset],
);
Navigator.pop(ctx);
AutoRouter.of(context).pop();
} else {
await ref.read(assetStackServiceProvider).updateStack(
currentAsset,
childrenToRemove: [
stackElements.elementAt(stackIndex.value),
],
);
removeAssetFromStack();
Navigator.pop(ctx);
}
},
title: const Text(
"viewer_remove_from_stack",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(
Icons.filter_none_outlined,
size: 18,
),
onTap: () async {
await ref.read(assetStackServiceProvider).updateStack(
currentAsset,
childrenToRemove: stack,
);
Navigator.pop(ctx);
AutoRouter.of(context).pop();
},
title: const Text(
"viewer_unstack",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
],
),
),
);
},
);
}
Widget buildBottomBar() {
// !!!! itemsList and actionlist should always be in sync
final itemsList = [
BottomNavigationBarItem(
icon: Icon(
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
),
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),
asset().isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(),
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'control_bottom_app_bar_archive'.tr(),
tooltip: 'control_bottom_app_bar_archive'.tr(),
),
if (stack.isNotEmpty)
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
label: 'control_bottom_app_bar_stack'.tr(),
tooltip: 'control_bottom_app_bar_stack'.tr(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
];
List<Function(int)> actionslist = [
(_) => shareAsset(),
(_) => handleArchive(asset()),
if (stack.isNotEmpty) (_) => showStackActionItems(),
(_) => handleDelete(asset()),
];
return IgnorePointer(
ignoring: !ref.watch(showControlsProvider),
child: AnimatedOpacity(
@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column(
children: [
if (stack.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 10,
bottom: 30,
),
child: SizedBox(
height: 40,
child: buildStackedChildren(),
),
),
Visibility(
visible: !asset().isImage && !isPlayingMotionVideo.value,
child: Container(
@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
selectedLabelStyle: const TextStyle(color: Colors.black),
showSelectedLabels: false,
showUnselectedLabels: false,
items: [
BottomNavigationBarItem(
icon: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
),
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),
asset().isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(),
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'control_bottom_app_bar_archive'.tr(),
tooltip: 'control_bottom_app_bar_archive'.tr(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
],
items: itemsList,
onTap: (index) {
switch (index) {
case 0:
shareAsset();
break;
case 1:
handleArchive(asset());
break;
case 2:
handleDelete(asset());
break;
if (index < actionslist.length) {
actionslist[index].call(index);
}
},
),
@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next);
currentIndex.value = value;
stackIndex.value = -1;
HapticFeedback.selectionClick();
},
loadingBuilder: (context, event, index) {
@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
: webPThumbnail;
},
builder: (context, index) {
final asset = loadAsset(index);
final ImageProvider provider = finalImageProvider(asset);
final a =
index == currentIndex.value ? asset() : loadAsset(index);
final ImageProvider provider = finalImageProvider(a);
if (asset.isImage && !isPlayingMotionVideo.value) {
if (a.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
},
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset,
tag: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
tightMode: true,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage(
asset,
a,
fit: BoxFit.contain,
),
);
@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset,
tag: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false,
asset: asset,
asset: a,
isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image(
image: provider,

View file

@ -0,0 +1,47 @@
import 'package:immich_mobile/shared/models/asset.dart';
class SelectionAssetState {
final bool hasRemote;
final bool hasLocal;
final bool hasMerged;
const SelectionAssetState({
this.hasRemote = false,
this.hasLocal = false,
this.hasMerged = false,
});
SelectionAssetState copyWith({
bool? hasRemote,
bool? hasLocal,
bool? hasMerged,
}) {
return SelectionAssetState(
hasRemote: hasRemote ?? this.hasRemote,
hasLocal: hasLocal ?? this.hasLocal,
hasMerged: hasMerged ?? this.hasMerged,
);
}
SelectionAssetState.fromSelection(Set<Asset> selection)
: hasLocal = selection.any((e) => e.storage == AssetState.local),
hasMerged = selection.any((e) => e.storage == AssetState.merged),
hasRemote = selection.any((e) => e.storage == AssetState.remote);
@override
String toString() =>
'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)';
@override
bool operator ==(covariant SelectionAssetState other) {
if (identical(this, other)) return true;
return other.hasRemote == hasRemote &&
other.hasLocal == hasLocal &&
other.hasMerged == hasMerged;
}
@override
int get hashCode =>
hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode;
}

View file

@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
final Widget? topWidget;
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
const ImmichAssetGrid({
super.key,
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.topWidget,
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
});
@override
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
heroOffset: heroOffset(),
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
showStack: showStack,
),
);
}

View file

@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
final int heroOffset;
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
const ImmichAssetGridView({
super.key,
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.heroOffset = 0,
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
});
@override
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
bool _scrolling = false;
final Set<Asset> _selectedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets);
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAssets(List<Asset> assets) {
setState(() {
_selectedAssets.removeAll(assets);
_selectedAssets.removeAll(
assets.where(
(a) =>
widget.canDeselect ||
!(widget.preselectedAssets?.contains(a) ?? false),
),
);
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset,
showStack: widget.showStack,
);
}
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
setState(() {
_selectedAssets.clear();
});
} else if (widget.preselectedAssets != null) {
setState(() {
_selectedAssets.addAll(widget.preselectedAssets!);
});
}
}

View file

@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
final Asset Function(int index) loadAsset;
final int totalAssets;
final bool showStorageIndicator;
final bool showStack;
final bool useGrayBoxPlaceholder;
final bool isSelected;
final bool multiselectEnabled;
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
required this.loadAsset,
required this.totalAssets,
this.showStorageIndicator = true,
this.showStack = false,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget {
);
}
Widget buildStackIcon() {
return Positioned(
top: 5,
right: 5,
child: Row(
children: [
if (asset.stackCount > 1)
Text(
"${asset.stackCount}",
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
if (asset.stackCount > 1)
const SizedBox(
width: 3,
),
const Icon(
Icons.burst_mode_rounded,
color: Colors.white,
size: 18,
),
],
),
);
}
Widget buildImage() {
final image = SizedBox(
width: 300,
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
decoration: BoxDecoration(
border: Border.all(
width: 0,
color: assetContainerColor,
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
color: assetContainerColor,
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
);
}
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
),
),
if (!asset.isImage) buildVideoIcon(),
if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
],
),
);

View file

@ -1,18 +1,16 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget {
final void Function() onShare;
final void Function(bool shareLocal) onShare;
final void Function() onFavorite;
final void Function() onArchive;
final void Function() onDelete;
@ -20,11 +18,12 @@ class ControlBottomAppBar extends ConsumerWidget {
final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function() onStack;
final List<Album> albums;
final List<Album> sharedAlbums;
final bool enabled;
final AssetState selectionAssetState;
final SelectionAssetState selectionAssetState;
const ControlBottomAppBar({
Key? key,
@ -38,32 +37,37 @@ class ControlBottomAppBar extends ConsumerWidget {
required this.onAddToAlbum,
required this.onCreateNewAlbum,
required this.onUpload,
this.selectionAssetState = AssetState.remote,
required this.onStack,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var hasRemote = selectionAssetState == AssetState.remote ||
selectionAssetState == AssetState.merged;
var hasLocal = selectionAssetState == AssetState.merged ||
selectionAssetState == AssetState.local;
var hasRemote =
selectionAssetState.hasRemote || selectionAssetState.hasMerged;
var hasLocal =
selectionAssetState.hasLocal || selectionAssetState.hasMerged;
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
List<Widget> renderActionButtons() {
return [
if (hasRemote)
ControlBoxButton(
iconData: Icons.share_rounded,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? () => onShare(false) : null,
),
ControlBoxButton(
iconData: Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? onShare : null,
iconData: Icons.ios_share_rounded,
label: "control_bottom_app_bar_share_to".tr(),
onPressed: enabled ? () => onShare(true) : null,
),
if (hasRemote)
ControlBoxButton(
iconData: Icons.archive_outlined,
iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(),
onPressed: enabled ? onArchive : null,
),
@ -114,6 +118,12 @@ class ControlBottomAppBar extends ConsumerWidget {
: null,
),
),
if (!hasLocal)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
if (!hasRemote)
ControlBoxButton(
iconData: Icons.backup_outlined,
@ -135,7 +145,7 @@ class ControlBottomAppBar extends ConsumerWidget {
return DraggableScrollableSheet(
initialChildSize: hasRemote ? 0.30 : 0.18,
minChildSize: 0.18,
maxChildSize: hasRemote ? 0.57 : 0.18,
maxChildSize: hasRemote ? 0.60 : 0.18,
snap: true,
builder: (
BuildContext context,
@ -191,10 +201,6 @@ class ControlBottomAppBar extends ConsumerWidget {
enabled: enabled,
),
),
if (hasRemote)
const SliverToBoxAdapter(
child: SizedBox(height: 200),
),
],
),
);
@ -227,7 +233,10 @@ class AddToAlbumTitleRow extends StatelessWidget {
).tr(),
TextButton.icon(
onPressed: onCreateNewAlbum,
icon: const Icon(Icons.add),
icon: Icon(
Icons.add,
color: Theme.of(context).primaryColor,
),
label: Text(
"common_create_new_album",
style: TextStyle(

View file

@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selectionAssetState = useState(AssetState.remote);
final selectionAssetState = useState(const SelectionAssetState());
final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
@ -83,17 +87,8 @@ class HomePage extends HookConsumerWidget {
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
? selectedAssets.any((e) => e.isLocal)
? AssetState.merged
: AssetState.remote
: AssetState.local;
}
void onShareAssets() {
handleShareAssets(ref, context, selection.value.toList());
selectionEnabledHook.value = false;
selectionAssetState.value =
SelectionAssetState.fromSelection(selectedAssets);
}
List<Asset> remoteOnlySelection({String? localErrorMessage}) {
@ -112,6 +107,19 @@ class HomePage extends HookConsumerWidget {
return assets.toList();
}
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
handleShareAssets(ref, context, selection.value.toList());
} else {
final ids = remoteOnlySelection().map((e) => e.remoteId!);
AutoRouter.of(context)
.push(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
}
void onFavoriteAssets() async {
processing.value = true;
try {
@ -270,6 +278,55 @@ class HomePage extends HookConsumerWidget {
}
}
void onStack() async {
try {
processing.value = true;
if (!selectionEnabledHook.value) {
return;
}
final selectedAsset = selection.value.elementAt(0);
if (selection.value.length == 1) {
final stackChildren =
(await ref.read(assetStackProvider(selectedAsset).future))
.toSet();
AssetSelectionPageResult? returnPayload =
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: stackChildren,
canDeselect: true,
query: getAssetStackSelectionQuery(ref, selectedAsset),
),
);
if (returnPayload != null) {
Set<Asset> selectedAssets = returnPayload.selectedAssets;
// Do not add itself as its stack child
selectedAssets.remove(selectedAsset);
final removedChildren = stackChildren.difference(selectedAssets);
final addedChildren = selectedAssets.difference(stackChildren);
await ref.read(assetStackServiceProvider).updateStack(
selectedAsset,
childrenToAdd: addedChildren.toList(),
childrenToRemove: removedChildren.toList(),
);
}
} else {
// Merge assets
selection.value.remove(selectedAsset);
final selectedAssets = selection.value;
await ref.read(assetStackServiceProvider).updateStack(
selectedAsset,
childrenToAdd: selectedAssets.toList(),
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@ -346,6 +403,7 @@ class HomePage extends HookConsumerWidget {
currentUser.memoryEnabled!)
? const MemoryLane()
: const SizedBox(),
showStack: true,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator,
@ -364,6 +422,7 @@ class HomePage extends HookConsumerWidget {
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: onStack,
),
if (processing.value) const Center(child: ImmichLoadingIndicator()),
],

View file

@ -0,0 +1,107 @@
import 'package:openapi/api.dart';
enum SharedLinkSource { album, individual }
class SharedLink {
final String id;
final String title;
final bool allowDownload;
final bool allowUpload;
final String? thumbAssetId;
final String? description;
final DateTime? expiresAt;
final String key;
final bool showMetadata;
final SharedLinkSource type;
const SharedLink({
required this.id,
required this.title,
required this.allowDownload,
required this.allowUpload,
required this.thumbAssetId,
required this.description,
required this.expiresAt,
required this.key,
required this.showMetadata,
required this.type,
});
SharedLink copyWith({
String? id,
String? title,
String? thumbAssetId,
bool? allowDownload,
bool? allowUpload,
String? description,
DateTime? expiresAt,
String? key,
bool? showMetadata,
SharedLinkSource? type,
}) {
return SharedLink(
id: id ?? this.id,
title: title ?? this.title,
thumbAssetId: thumbAssetId ?? this.thumbAssetId,
allowDownload: allowDownload ?? this.allowDownload,
allowUpload: allowUpload ?? this.allowUpload,
description: description ?? this.description,
expiresAt: expiresAt ?? this.expiresAt,
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
type: type ?? this.type,
);
}
SharedLink.fromDto(SharedLinkResponseDto dto)
: id = dto.id,
allowDownload = dto.allowDownload,
allowUpload = dto.allowUpload,
description = dto.description,
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
type = dto.type == SharedLinkType.ALBUM
? SharedLinkSource.album
: SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
: "INDIVIDUAL SHARE",
thumbAssetId = dto.type == SharedLinkType.ALBUM
? dto.album?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
: null;
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SharedLink &&
other.id == id &&
other.title == title &&
other.thumbAssetId == thumbAssetId &&
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.description == description &&
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
other.type == type;
@override
int get hashCode =>
id.hashCode ^
title.hashCode ^
thumbAssetId.hashCode ^
allowDownload.hashCode ^
allowUpload.hashCode ^
description.hashCode ^
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^
type.hashCode;
}

View file

@ -0,0 +1,29 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart';
class SharedLinksNotifier extends StateNotifier<AsyncValue<List<SharedLink>>> {
final SharedLinkService _sharedLinkService;
SharedLinksNotifier(this._sharedLinkService) : super(const AsyncLoading()) {
fetchLinks();
}
Future<void> fetchLinks() async {
state = await _sharedLinkService.getAllSharedLinks();
}
Future<void> deleteLink(String id) async {
await _sharedLinkService.deleteSharedLink(id);
state = const AsyncLoading();
fetchLinks();
}
}
final sharedLinksStateProvider =
StateNotifierProvider<SharedLinksNotifier, AsyncValue<List<SharedLink>>>(
(ref) {
return SharedLinksNotifier(
ref.watch(sharedLinkServiceProvider),
);
});

View file

@ -0,0 +1,115 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final sharedLinkServiceProvider = Provider(
(ref) => SharedLinkService(ref.watch(apiServiceProvider)),
);
class SharedLinkService {
final ApiService _apiService;
final Logger _log = Logger("SharedLinkService");
SharedLinkService(this._apiService);
Future<AsyncValue<List<SharedLink>>> getAllSharedLinks() async {
try {
final list = await _apiService.sharedLinkApi.getAllSharedLinks();
return list != null
? AsyncData(list.map(SharedLink.fromDto).toList())
: const AsyncData([]);
} catch (e, stack) {
_log.severe("failed to fetch shared links - $e");
return AsyncError(e, stack);
}
}
Future<void> deleteSharedLink(String id) async {
try {
return await _apiService.sharedLinkApi.removeSharedLink(id);
} catch (e) {
_log.severe("failed to delete shared link id - $id with error - $e");
}
}
Future<SharedLink?> createSharedLink({
required bool showMeta,
required bool allowDownload,
required bool allowUpload,
String? description,
String? albumId,
List<String>? assetIds,
DateTime? expiresAt,
}) async {
try {
final type =
albumId != null ? SharedLinkType.ALBUM : SharedLinkType.INDIVIDUAL;
SharedLinkCreateDto? dto;
if (type == SharedLinkType.ALBUM) {
dto = SharedLinkCreateDto(
type: type,
albumId: albumId,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
type: type,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
assetIds: assetIds,
);
}
if (dto != null) {
final responseDto =
await _apiService.sharedLinkApi.createSharedLink(dto);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
}
} catch (e) {
_log.severe("failed to create shared link with error - $e");
}
return null;
}
Future<SharedLink?> updateSharedLink(
String id, {
required bool? showMeta,
required bool? allowDownload,
required bool? allowUpload,
bool? changeExpiry = false,
String? description,
DateTime? expiresAt,
}) async {
try {
final responseDto = await _apiService.sharedLinkApi.updateSharedLink(
id,
SharedLinkEditDto(
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
changeExpiryTime: changeExpiry,
),
);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
} catch (e) {
_log.severe("failed to update shared link id - $id with error - $e");
}
return null;
}
}

View file

@ -0,0 +1,307 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/url_helper.dart';
class SharedLinkItem extends ConsumerWidget {
final SharedLink sharedLink;
const SharedLinkItem(this.sharedLink, {super.key});
bool isExpired() {
if (sharedLink.expiresAt != null) {
return DateTime.now().isAfter(sharedLink.expiresAt!);
}
return false;
}
Widget getExpiryDuration(bool isDarkMode) {
var expiresText = "Expires ∞";
if (sharedLink.expiresAt != null) {
if (isExpired()) {
return Text(
"Expired",
style: TextStyle(color: Colors.red[300]),
);
}
final difference = sharedLink.expiresAt!.difference(DateTime.now());
debugPrint("Difference: $difference");
if (difference.inDays > 0) {
var dayDifference = difference.inDays;
if (difference.inHours % 24 > 12) {
dayDifference += 1;
}
expiresText = "in $dayDifference days";
} else if (difference.inHours > 0) {
expiresText = "in ${difference.inHours} hours";
} else if (difference.inMinutes > 0) {
expiresText = "in ${difference.inMinutes} minutes";
} else if (difference.inSeconds > 0) {
expiresText = "in ${difference.inSeconds} seconds";
}
}
return Text(
expiresText,
style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600]),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeData = Theme.of(context);
final isDarkMode = themeData.brightness == Brightness.dark;
final thumbnailUrl = sharedLink.thumbAssetId != null
? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!)
: null;
final imageSize = math.min(MediaQuery.of(context).size.width / 4, 100.0);
void copyShareLinkToClipboard() {
final serverUrl = getServerUrl();
if (serverUrl == null) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: 'Cannot fetch the server url',
);
return;
}
Clipboard.setData(
ClipboardData(
text: "$serverUrl/share/${sharedLink.key}",
),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Copied to clipboard",
),
duration: Duration(seconds: 2),
),
);
});
}
Future<void> deleteShareLink() async {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "delete_shared_link_dialog_title",
content: "delete_shared_link_dialog_content",
onOk: () => ref
.read(sharedLinksStateProvider.notifier)
.deleteLink(sharedLink.id),
);
},
);
}
Widget buildThumbnail() {
if (thumbnailUrl == null) {
return Container(
height: imageSize * 1.2,
width: imageSize,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: Center(
child: Icon(
Icons.image_not_supported_outlined,
color: isDarkMode ? Colors.grey[100] : Colors.grey[700],
),
),
);
}
return SizedBox(
height: imageSize * 1.2,
width: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailUrl,
key: key,
textInfo: '',
noImageIcon: Icons.image_not_supported_outlined,
onTap: () {},
),
),
);
}
Widget buildInfoChip(String labelText) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Chip(
backgroundColor: themeData.primaryColor,
label: Text(
labelText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.black : Colors.white,
),
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(25)),
),
),
);
}
Widget buildBottomInfo() {
return Row(
children: [
if (sharedLink.allowUpload) buildInfoChip("Upload"),
if (sharedLink.allowDownload) buildInfoChip("Download"),
if (sharedLink.showMetadata) buildInfoChip("EXIF"),
],
);
}
Widget buildSharedLinkActions() {
const actionIconSize = 20.0;
return Row(
children: [
IconButton(
splashRadius: 25,
constraints: const BoxConstraints(),
iconSize: actionIconSize,
icon: const Icon(Icons.delete_outline),
style: const ButtonStyle(
tapTargetSize:
MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: deleteShareLink,
),
IconButton(
splashRadius: 25,
constraints: const BoxConstraints(),
iconSize: actionIconSize,
icon: const Icon(Icons.edit_outlined),
style: const ButtonStyle(
tapTargetSize:
MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: () => AutoRouter.of(context)
.push(SharedLinkEditRoute(existingLink: sharedLink)),
),
IconButton(
splashRadius: 25,
constraints: const BoxConstraints(),
iconSize: actionIconSize,
icon: const Icon(Icons.copy_outlined),
style: const ButtonStyle(
tapTargetSize:
MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: copyShareLinkToClipboard,
),
],
);
}
Widget buildSharedLinkDetails() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
getExpiryDuration(isDarkMode),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: themeData.primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(10),
),
textStyle: TextStyle(
color: isDarkMode ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
message: sharedLink.title,
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(
sharedLink.title,
style: TextStyle(
color: themeData.primaryColor,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
),
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: themeData.primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(10),
),
textStyle: TextStyle(
color: isDarkMode ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
message: sharedLink.description ?? "",
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(
sharedLink.description ?? "",
overflow: TextOverflow.ellipsis,
),
),
),
Padding(
padding: const EdgeInsets.only(right: 15),
child: buildSharedLinkActions(),
),
],
),
buildBottomInfo(),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 15),
child: buildThumbnail(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 15),
child: buildSharedLinkDetails(),
),
),
],
),
const Padding(
padding: EdgeInsets.all(20),
child: Divider(
height: 0,
),
),
],
);
}
}

View file

@ -0,0 +1,459 @@
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:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart';
import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
class SharedLinkEditPage extends HookConsumerWidget {
final SharedLink? existingLink;
final List<String>? assetsList;
final String? albumId;
const SharedLinkEditPage({
super.key,
this.existingLink,
this.assetsList,
this.albumId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
const padding = 20.0;
final themeData = Theme.of(context);
final descriptionController =
useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
final editExpiry = useState(false);
final expiryAfter = useState(0);
final newShareLink = useState("");
Widget buildLinkTitle() {
if (existingLink != null) {
if (existingLink!.type == SharedLinkSource.album) {
return Row(
children: [
const Text(
"Public album | ",
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
existingLink!.title,
style: TextStyle(
color: themeData.primaryColor,
fontWeight: FontWeight.bold,
),
),
],
);
}
if (existingLink!.type == SharedLinkSource.individual) {
return Row(
children: [
const Text(
"Individual shared | ",
style: TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
child: Text(
existingLink!.description ?? "--",
style: TextStyle(
color: themeData.primaryColor,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
return const Text(
"shared_link_create_info",
style: TextStyle(fontWeight: FontWeight.bold),
).tr();
}
Widget buildDescriptionField() {
return TextField(
controller: descriptionController,
enabled: newShareLink.value.isEmpty,
focusNode: descriptionFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'shared_link_edit_description'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: themeData.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_description_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
),
onTapOutside: (_) => descriptionFocusNode.unfocus(),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty
? (value) => showMetadata.value = value
: null,
activeColor: themeData.primaryColor,
dense: true,
title: Text(
"shared_link_edit_show_meta",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildAllowDownloadButton() {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty
? (value) => allowDownload.value = value
: null,
activeColor: themeData.primaryColor,
dense: true,
title: Text(
"shared_link_edit_allow_download",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildAllowUploadButton() {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty
? (value) => allowUpload.value = value
: null,
activeColor: themeData.primaryColor,
dense: true,
title: Text(
"shared_link_edit_allow_upload",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildEditExpiryButton() {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty
? (value) => editExpiry.value = value
: null,
activeColor: themeData.primaryColor,
dense: true,
title: Text(
"shared_link_edit_change_expiry",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildExpiryAfterButton() {
return DropdownMenu(
enableSearch: false,
enableFilter: false,
width: MediaQuery.of(context).size.width - 40,
initialSelection: expiryAfter.value,
enabled: newShareLink.value.isEmpty &&
(existingLink == null || editExpiry.value),
onSelected: (value) {
expiryAfter.value = value!;
},
inputDecorationTheme: themeData.inputDecorationTheme.copyWith(
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
),
),
dropdownMenuEntries: const [
DropdownMenuEntry(value: 0, label: "Never"),
DropdownMenuEntry(
value: 30,
label: '30 minutes',
),
DropdownMenuEntry(
value: 60,
label: '1 hour',
),
DropdownMenuEntry(
value: 60 * 6,
label: '6 hours',
),
DropdownMenuEntry(
value: 60 * 24,
label: '1 day',
),
DropdownMenuEntry(
value: 60 * 24 * 7,
label: '7 days',
),
DropdownMenuEntry(
value: 60 * 24 * 30,
label: '30 days',
),
],
);
}
void copyLinkToClipboard() {
Clipboard.setData(
ClipboardData(
text: newShareLink.value,
),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Copied to clipboard",
),
duration: Duration(seconds: 2),
),
);
});
}
Widget buildNewLinkField() {
return Column(
children: [
const Padding(
padding: EdgeInsets.only(
top: 20,
bottom: 20,
),
child: Divider(),
),
TextFormField(
readOnly: true,
initialValue: newShareLink.value,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(
onPressed: copyLinkToClipboard,
icon: const Icon(Icons.copy),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Align(
alignment: Alignment.bottomRight,
child: ElevatedButton(
onPressed: () {
AutoRouter.of(context).pop();
},
child: const Text(
"Done",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
),
],
);
}
DateTime calculateExpiry() {
return DateTime.now().add(Duration(minutes: expiryAfter.value));
}
Future<void> handleNewLink() async {
final newLink =
await ref.read(sharedLinkServiceProvider).createSharedLink(
albumId: albumId,
assetIds: assetsList,
showMeta: showMetadata.value,
allowDownload: allowDownload.value,
allowUpload: allowUpload.value,
description: descriptionController.text.isEmpty
? null
: descriptionController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
final serverUrl = getServerUrl();
if (newLink != null && serverUrl != null) {
newShareLink.value = "$serverUrl/share/${newLink.key}";
copyLinkToClipboard();
} else if (newLink == null) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: 'Error while creating shared link',
);
}
}
Future<void> handleEditLink() async {
bool? download;
bool? upload;
bool? meta;
String? desc;
DateTime? expiry;
bool? changeExpiry;
if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value;
}
if (allowUpload.value != existingLink!.allowUpload) {
upload = allowUpload.value;
}
if (showMetadata.value != existingLink!.showMetadata) {
meta = showMetadata.value;
}
if (descriptionController.text != existingLink!.description) {
desc = descriptionController.text;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
}
await ref.read(sharedLinkServiceProvider).updateSharedLink(
existingLink!.id,
showMeta: meta,
allowDownload: download,
allowUpload: upload,
description: desc,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
ref.invalidate(sharedLinksStateProvider);
AutoRouter.of(context).pop();
}
return Scaffold(
appBar: AppBar(
title: Text(
existingLink == null
? "shared_link_create_app_bar_title"
: "shared_link_edit_app_bar_title",
).tr(),
elevation: 0,
leading: const CloseButton(),
centerTitle: false,
),
resizeToAvoidBottomInset: false,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(padding),
child: buildLinkTitle(),
),
Padding(
padding: const EdgeInsets.all(padding),
child: buildDescriptionField(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildShowMetaButton(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildAllowDownloadButton(),
),
Padding(
padding:
const EdgeInsets.only(left: padding, right: 20, bottom: 20),
child: buildAllowUploadButton(),
),
if (existingLink != null)
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildEditExpiryButton(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildExpiryAfterButton(),
),
if (newShareLink.value.isEmpty)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: padding + 10),
child: ElevatedButton(
onPressed:
existingLink != null ? handleEditLink : handleNewLink,
child: Text(
existingLink != null
? "shared_link_edit_submit_button"
: "shared_link_create_submit_button",
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
),
if (newShareLink.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
),
child: buildNewLinkField(),
),
],
),
),
);
}
}

View file

@ -0,0 +1,126 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart';
import 'package:immich_mobile/modules/shared_link/ui/shared_link_item.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SharedLinkPage extends HookConsumerWidget {
const SharedLinkPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedLinks = ref.watch(sharedLinksStateProvider);
useEffect(
() {
ref.read(sharedLinksStateProvider.notifier).fetchLinks();
return () => ref.invalidate(sharedLinksStateProvider);
},
[],
);
Widget buildNoShares() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: const Text(
"shared_link_manage_links",
style: TextStyle(
fontSize: 14,
color: Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: const Text(
"shared_link_empty",
style: TextStyle(fontSize: 14),
).tr(),
),
),
Expanded(
child: Center(
child: Icon(
Icons.link_off,
size: 100,
color: Theme.of(context).iconTheme.color?.withOpacity(0.5),
),
),
),
],
);
}
Widget buildSharesList(List<SharedLink> links) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
child: const Text(
"shared_link_manage_links",
style: TextStyle(
fontSize: 14,
color: Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// Two column
return GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 180,
),
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
}
// Single column
return ListView.builder(
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
},
),
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("shared_link_app_bar_title").tr(),
elevation: 0,
centerTitle: false,
),
body: SafeArea(
child: sharedLinks.when(
data: (links) =>
links.isNotEmpty ? buildSharesList(links) : buildNoShares(),
error: (error, stackTrace) => buildNoShares(),
loading: () => const Center(child: ImmichLoadingIndicator()),
),
),
);
}
}

View file

@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false,
showStack: true,
topWidget: Padding(
padding: const EdgeInsets.only(
top: 24,

View file

@ -28,6 +28,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart';
import 'package:immich_mobile/modules/trash/views/trash_page.dart';
import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_people_page.dart';
@ -51,6 +54,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart';
import 'package:immich_mobile/shared/views/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart';
part 'router.gr.dart';
@ -157,6 +161,8 @@ part 'router.gr.dart';
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]),
],
)
class AppRouter extends _$AppRouter {

View file

@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
loadAsset: args.loadAsset,
totalAssets: args.totalAssets,
heroOffset: args.heroOffset,
showStack: args.showStack,
),
);
},
@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
child: AssetSelectionPage(
key: args.key,
existingAssets: args.existingAssets,
isNewAlbum: args.isNewAlbum,
canDeselect: args.canDeselect,
query: args.query,
),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
@ -318,6 +320,25 @@ class _$AppRouter extends RootStackRouter {
child: const TrashPage(),
);
},
SharedLinkRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SharedLinkPage(),
);
},
SharedLinkEditRoute.name: (routeData) {
final args = routeData.argsAs<SharedLinkEditRouteArgs>(
orElse: () => const SharedLinkEditRouteArgs());
return MaterialPageX<dynamic>(
routeData: routeData,
child: SharedLinkEditPage(
key: args.key,
existingLink: args.existingLink,
assetsList: args.assetsList,
albumId: args.albumId,
),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@ -638,6 +659,22 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
SharedLinkRoute.name,
path: '/shared-link-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
SharedLinkEditRoute.name,
path: '/shared-link-edit-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@ -711,6 +748,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
required Asset Function(int) loadAsset,
required int totalAssets,
int heroOffset = 0,
bool showStack = false,
}) : super(
GalleryViewerRoute.name,
path: '/gallery-viewer-page',
@ -720,6 +758,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
);
@ -733,6 +772,7 @@ class GalleryViewerRouteArgs {
required this.loadAsset,
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
});
final Key? key;
@ -745,9 +785,11 @@ class GalleryViewerRouteArgs {
final int heroOffset;
final bool showStack;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}';
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
}
}
@ -961,14 +1003,16 @@ class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> {
AssetSelectionRoute({
Key? key,
required Set<Asset> existingAssets,
bool isNewAlbum = false,
bool canDeselect = false,
required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
}) : super(
AssetSelectionRoute.name,
path: '/asset-selection-page',
args: AssetSelectionRouteArgs(
key: key,
existingAssets: existingAssets,
isNewAlbum: isNewAlbum,
canDeselect: canDeselect,
query: query,
),
);
@ -979,18 +1023,21 @@ class AssetSelectionRouteArgs {
const AssetSelectionRouteArgs({
this.key,
required this.existingAssets,
this.isNewAlbum = false,
this.canDeselect = false,
required this.query,
});
final Key? key;
final Set<Asset> existingAssets;
final bool isNewAlbum;
final bool canDeselect;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
@override
String toString() {
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}';
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}';
}
}
@ -1420,6 +1467,62 @@ class TrashRoute extends PageRouteInfo<void> {
static const String name = 'TrashRoute';
}
/// generated route for
/// [SharedLinkPage]
class SharedLinkRoute extends PageRouteInfo<void> {
const SharedLinkRoute()
: super(
SharedLinkRoute.name,
path: '/shared-link-page',
);
static const String name = 'SharedLinkRoute';
}
/// generated route for
/// [SharedLinkEditPage]
class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {
SharedLinkEditRoute({
Key? key,
SharedLink? existingLink,
List<String>? assetsList,
String? albumId,
}) : super(
SharedLinkEditRoute.name,
path: '/shared-link-edit-page',
args: SharedLinkEditRouteArgs(
key: key,
existingLink: existingLink,
assetsList: assetsList,
albumId: albumId,
),
);
static const String name = 'SharedLinkEditRoute';
}
class SharedLinkEditRouteArgs {
const SharedLinkEditRouteArgs({
this.key,
this.existingLink,
this.assetsList,
this.albumId,
});
final Key? key;
final SharedLink? existingLink;
final List<String>? assetsList;
final String? albumId;
@override
String toString() {
return 'SharedLinkEditRouteArgs{key: $key, existingLink: $existingLink, assetsList: $assetsList, albumId: $albumId}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View file

@ -31,7 +31,9 @@ class Asset {
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite,
isArchived = remote.isArchived,
isTrashed = remote.isTrashed;
isTrashed = remote.isTrashed,
stackParentId = remote.stackParentId,
stackCount = remote.stackCount;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
@ -47,6 +49,7 @@ class Asset {
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
stackCount = 0,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
@ -77,6 +80,8 @@ class Asset {
required this.isFavorite,
required this.isArchived,
required this.isTrashed,
this.stackParentId,
required this.stackCount,
});
@ignore
@ -146,6 +151,10 @@ class Asset {
@ignore
ExifInfo? exifInfo;
String? stackParentId;
int stackCount;
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;
@ -200,7 +209,9 @@ class Asset {
isFavorite == other.isFavorite &&
isLocal == other.isLocal &&
isArchived == other.isArchived &&
isTrashed == other.isTrashed;
isTrashed == other.isTrashed &&
stackCount == other.stackCount &&
stackParentId == other.stackParentId;
}
@override
@ -223,7 +234,9 @@ class Asset {
isFavorite.hashCode ^
isLocal.hashCode ^
isArchived.hashCode ^
isTrashed.hashCode;
isTrashed.hashCode ^
stackCount.hashCode ^
stackParentId.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) {
@ -236,9 +249,11 @@ class Asset {
width == null && a.width != null ||
height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
stackParentId == null && a.stackParentId != null ||
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed;
isTrashed != a.isTrashed ||
stackCount != a.stackCount;
}
/// Returns a new [Asset] with values from this and merged & updated with [a]
@ -267,6 +282,8 @@ class Asset {
id: id,
remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId,
stackParentId: stackParentId,
stackCount: stackCount,
isFavorite: isFavorite,
isArchived: isArchived,
isTrashed: isTrashed,
@ -281,6 +298,8 @@ class Asset {
width: a.width,
height: a.height,
livePhotoVideoId: a.livePhotoVideoId,
stackParentId: a.stackParentId,
stackCount: a.stackCount,
// isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite,
isArchived: a.isArchived,
@ -318,6 +337,8 @@ class Asset {
bool? isArchived,
bool? isTrashed,
ExifInfo? exifInfo,
String? stackParentId,
int? stackCount,
}) =>
Asset(
id: id ?? this.id,
@ -338,6 +359,8 @@ class Asset {
isArchived: isArchived ?? this.isArchived,
isTrashed: isTrashed ?? this.isTrashed,
exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId,
stackCount: stackCount ?? this.stackCount,
);
Future<void> put(Isar db) async {
@ -379,6 +402,8 @@ class Asset {
"checksum": "$checksum",
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"stackCount": "$stackCount",
"stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",

View file

@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema(
name: r'remoteId',
type: IsarType.string,
),
r'type': PropertySchema(
r'stackCount': PropertySchema(
id: 13,
name: r'stackCount',
type: IsarType.long,
),
r'stackParentId': PropertySchema(
id: 14,
name: r'stackParentId',
type: IsarType.string,
),
r'type': PropertySchema(
id: 15,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 14,
id: 16,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 15,
id: 17,
name: r'width',
type: IsarType.int,
)
@ -184,6 +194,12 @@ int _assetEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.stackParentId;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount;
}
@ -206,9 +222,11 @@ void _assetSerialize(
writer.writeString(offsets[10], object.localId);
writer.writeLong(offsets[11], object.ownerId);
writer.writeString(offsets[12], object.remoteId);
writer.writeByte(offsets[13], object.type.index);
writer.writeDateTime(offsets[14], object.updatedAt);
writer.writeInt(offsets[15], object.width);
writer.writeLong(offsets[13], object.stackCount);
writer.writeString(offsets[14], object.stackParentId);
writer.writeByte(offsets[15], object.type.index);
writer.writeDateTime(offsets[16], object.updatedAt);
writer.writeInt(offsets[17], object.width);
}
Asset _assetDeserialize(
@ -232,10 +250,12 @@ Asset _assetDeserialize(
localId: reader.readStringOrNull(offsets[10]),
ownerId: reader.readLong(offsets[11]),
remoteId: reader.readStringOrNull(offsets[12]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
stackCount: reader.readLong(offsets[13]),
stackParentId: reader.readStringOrNull(offsets[14]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[15])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[14]),
width: reader.readIntOrNull(offsets[15]),
updatedAt: reader.readDateTime(offsets[16]),
width: reader.readIntOrNull(offsets[17]),
);
return object;
}
@ -274,11 +294,15 @@ P _assetDeserializeProp<P>(
case 12:
return (reader.readStringOrNull(offset)) as P;
case 13:
return (reader.readLong(offset)) as P;
case 14:
return (reader.readStringOrNull(offset)) as P;
case 15:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 14:
case 16:
return (reader.readDateTime(offset)) as P;
case 15:
case 17:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@ -1801,6 +1825,205 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountEqualTo(
int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackCount',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'stackCount',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'stackCount',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'stackCount',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'stackParentId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'stackParentId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'stackParentId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'stackParentId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'stackParentId',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackParentId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'stackParentId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
AssetType value) {
return QueryBuilder.apply(this, (query) {
@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCount() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCountDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCount() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCountDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackCount', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByStackCount() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'stackCount');
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByStackParentId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'stackParentId',
caseSensitive: caseSensitive);
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type');
@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, int, QQueryOperations> stackCountProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackCount');
});
}
QueryBuilder<Asset, String?, QQueryOperations> stackParentIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackParentId');
});
}
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type');

View file

@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@ -254,6 +255,7 @@ final assetsProvider =
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
@ -264,10 +266,12 @@ final assetsProvider =
}
});
final remoteAssetsProvider =
StreamProvider.family<RenderList, int?>((ref, userId) async* {
if (userId == null) return;
final query = ref
QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
final userId = ref.watch(currentUserProvider)?.isarId;
if (userId == null) {
return null;
}
return ref
.watch(dbProvider)
.assets
.where()
@ -275,12 +279,34 @@ final remoteAssetsProvider =
.filter()
.ownerIdEqualTo(userId)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
QueryBuilder<Asset, Asset, QAfterSortBy>? getAssetStackSelectionQuery(
WidgetRef ref,
Asset parentAsset,
) {
final userId = ref.watch(currentUserProvider)?.isarId;
if (userId == null || !parentAsset.isRemote) {
return null;
}
});
return ref
.watch(dbProvider)
.assets
.where()
.remoteIdIsNotNull()
.filter()
.isArchivedEqualTo(false)
.ownerIdEqualTo(userId)
.not()
.remoteIdEqualTo(parentAsset.remoteId)
// Show existing stack children in selection page
.group(
(q) => q
.stackParentIdIsNull()
.or()
.stackParentIdEqualTo(parentAsset.remoteId),
)
.sortByFileCreatedAtDesc();
}

View file

@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}

View file

@ -21,6 +21,7 @@ class ApiService {
late PartnerApi partnerApi;
late PersonApi personApi;
late AuditApi auditApi;
late SharedLinkApi sharedLinkApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -45,6 +46,7 @@ class ApiService {
partnerApi = PartnerApi(_apiClient);
personApi = PersonApi(_apiClient);
auditApi = AuditApi(_apiClient);
sharedLinkApi = SharedLinkApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'api.service.dart';
@ -13,32 +14,60 @@ final shareServiceProvider =
class ShareService {
final ApiService _apiService;
final Logger _log = Logger("ShareService");
ShareService(this._apiService);
Future<void> shareAsset(Asset asset) async {
await shareAssets([asset]);
Future<bool> shareAsset(Asset asset) async {
return await shareAssets([asset]);
}
Future<void> shareAssets(List<Asset> assets) async {
final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
if (asset.isRemote) {
final tempDir = await getTemporaryDirectory();
final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!);
tempFile.writeAsBytesSync(res.bodyBytes);
return XFile(tempFile.path);
} else {
File? f = await asset.local!.file;
return XFile(f!.path);
}
});
Future<bool> shareAssets(List<Asset> assets) async {
try {
final downloadedXFiles = <XFile>[];
Share.shareXFiles(
await Future.wait(downloadedXFiles),
sharePositionOrigin: Rect.zero,
);
for (var asset in assets) {
if (asset.isRemote) {
final tempDir = await getTemporaryDirectory();
final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) {
_log.severe(
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
);
continue;
}
tempFile.writeAsBytesSync(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
} else {
File? f = await asset.local!.file;
downloadedXFiles.add(XFile(f!.path));
}
}
if (downloadedXFiles.isEmpty) {
_log.warning("No asset can be retrieved for share");
return false;
}
if (downloadedXFiles.length != assets.length) {
_log.warning(
"Partial share - Requested: ${assets.length}, Sharing: ${downloadedXFiles.length}",
);
}
Share.shareXFiles(
downloadedXFiles,
sharePositionOrigin: Rect.zero,
);
return true;
} catch (error) {
_log.severe("Share failed with error $error");
}
return false;
}
}

View file

@ -41,7 +41,12 @@ ThemeData immichLightTheme = ThemeData(
fontFamily: 'WorkSans',
scaffoldBackgroundColor: immichBackgroundColor,
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
contentTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
backgroundColor: Colors.white,
),
appBarTheme: AppBarTheme(
titleTextStyle: const TextStyle(
@ -156,8 +161,13 @@ ThemeData immichDarkTheme = ThemeData(
scaffoldBackgroundColor: immichDarkBackgroundColor,
hintColor: Colors.grey[600],
fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
snackBarTheme: SnackBarThemeData(
contentTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: immichDarkThemePrimaryColor,
fontWeight: FontWeight.bold,
),
backgroundColor: Colors.grey[900],
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(

View file

@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -15,10 +16,19 @@ void handleShareAssets(
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref
.watch(shareServiceProvider)
.shareAssets(selection.toList())
.then((_) => Navigator.of(buildContext).pop());
ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
Navigator.of(buildContext).pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,

View file

@ -1,3 +1,5 @@
import 'package:immich_mobile/shared/models/store.dart';
String sanitizeUrl(String url) {
// Add schema if none is set
final urlWithSchema =
@ -6,3 +8,15 @@ String sanitizeUrl(String url) {
// Remove trailing slash(es)
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
}
String? getServerUrl() {
final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;
}
return serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}";
}

View file

@ -149,6 +149,7 @@ doc/TranscodePolicy.md
doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md
doc/UpdateLibraryDto.md
doc/UpdateStackParentDto.md
doc/UpdateTagDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart
lib/model/update_library_dto.dart
lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
@ -468,6 +470,7 @@ test/transcode_policy_test.dart
test/update_album_dto_test.dart
test/update_asset_dto_test.dart
test/update_library_dto_test.dart
test/update_stack_parent_dto_test.dart
test/update_tag_dto_test.dart
test/update_user_dto_test.dart
test/usage_by_user_dto_test.dart

View file

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.82.0
- API version: 1.82.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |
*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset |
*AssetApi* | [**updateStackParent**](doc//AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
@ -330,6 +331,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UpdateStackParentDto](doc//UpdateStackParentDto.md)
- [UpdateTagDto](doc//UpdateTagDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)

View file

@ -38,6 +38,7 @@ Method | HTTP request | Description
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |
[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} |
[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset |
[**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |
[**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload |
@ -1696,6 +1697,60 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateStackParent**
> updateStackParent(updateStackParentDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final updateStackParentDto = UpdateStackParentDto(); // UpdateStackParentDto |
try {
api_instance.updateStackParent(updateStackParentDto);
} catch (e) {
print('Exception when calling AssetApi->updateStackParent: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**updateStackParentDto** | [**UpdateStackParentDto**](UpdateStackParentDto.md)| |
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)

View file

@ -11,6 +11,8 @@ Name | Type | Description | Notes
**ids** | **List<String>** | | [default to const []]
**isArchived** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional]
**removeParent** | **bool** | | [optional]
**stackParentId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -33,6 +33,9 @@ Name | Type | Description | Notes
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
**resized** | **bool** | |
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [optional] [default to const []]
**stackCount** | **int** | |
**stackParentId** | **String** | | [optional]
**tags** | [**List<TagResponseDto>**](TagResponseDto.md) | | [optional] [default to const []]
**thumbhash** | **String** | |
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) | |

View file

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**allowDownload** | **bool** | | [optional]
**allowUpload** | **bool** | | [optional]
**changeExpiryTime** | **bool** | Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. | [optional]
**description** | **String** | | [optional]
**expiresAt** | [**DateTime**](DateTime.md) | | [optional]
**showMetadata** | **bool** | | [optional]

View file

@ -0,0 +1,16 @@
# openapi.model.UpdateStackParentDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**newParentId** | **String** | |
**oldParentId** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -176,6 +176,7 @@ part 'model/transcode_policy.dart';
part 'model/update_album_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart';
part 'model/update_stack_parent_dto.dart';
part 'model/update_tag_dto.dart';
part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart';

View file

@ -1654,6 +1654,45 @@ class AssetApi {
}
}
/// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response].
/// Parameters:
///
/// * [UpdateStackParentDto] updateStackParentDto (required):
Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/stack/parent';
// ignore: prefer_final_locals
Object? postBody = updateStackParentDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [UpdateStackParentDto] updateStackParentDto (required):
Future<void> updateStackParent(UpdateStackParentDto updateStackParentDto,) async {
final response = await updateStackParentWithHttpInfo(updateStackParentDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
/// Parameters:
///

View file

@ -443,6 +443,8 @@ class ApiClient {
return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value);
case 'UpdateStackParentDto':
return UpdateStackParentDto.fromJson(value);
case 'UpdateTagDto':
return UpdateTagDto.fromJson(value);
case 'UpdateUserDto':

View file

@ -16,6 +16,8 @@ class AssetBulkUpdateDto {
this.ids = const [],
this.isArchived,
this.isFavorite,
this.removeParent,
this.stackParentId,
});
List<String> ids;
@ -36,21 +38,41 @@ class AssetBulkUpdateDto {
///
bool? isFavorite;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? removeParent;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? stackParentId;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
other.ids == ids &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite;
other.isFavorite == isFavorite &&
other.removeParent == removeParent &&
other.stackParentId == stackParentId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode);
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(removeParent == null ? 0 : removeParent!.hashCode) +
(stackParentId == null ? 0 : stackParentId!.hashCode);
@override
String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]';
String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -65,6 +87,16 @@ class AssetBulkUpdateDto {
} else {
// json[r'isFavorite'] = null;
}
if (this.removeParent != null) {
json[r'removeParent'] = this.removeParent;
} else {
// json[r'removeParent'] = null;
}
if (this.stackParentId != null) {
json[r'stackParentId'] = this.stackParentId;
} else {
// json[r'stackParentId'] = null;
}
return json;
}
@ -81,6 +113,8 @@ class AssetBulkUpdateDto {
: const [],
isArchived: mapValueOfType<bool>(json, r'isArchived'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
removeParent: mapValueOfType<bool>(json, r'removeParent'),
stackParentId: mapValueOfType<String>(json, r'stackParentId'),
);
}
return null;

View file

@ -38,6 +38,9 @@ class AssetResponseDto {
this.people = const [],
required this.resized,
this.smartInfo,
this.stack = const [],
required this.stackCount,
this.stackParentId,
this.tags = const [],
required this.thumbhash,
required this.type,
@ -113,6 +116,12 @@ class AssetResponseDto {
///
SmartInfoResponseDto? smartInfo;
List<AssetResponseDto> stack;
int stackCount;
String? stackParentId;
List<TagResponseDto> tags;
String? thumbhash;
@ -148,6 +157,9 @@ class AssetResponseDto {
other.people == people &&
other.resized == resized &&
other.smartInfo == smartInfo &&
other.stack == stack &&
other.stackCount == stackCount &&
other.stackParentId == stackParentId &&
other.tags == tags &&
other.thumbhash == thumbhash &&
other.type == type &&
@ -181,13 +193,16 @@ class AssetResponseDto {
(people.hashCode) +
(resized.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(stack.hashCode) +
(stackCount.hashCode) +
(stackParentId == null ? 0 : stackParentId!.hashCode) +
(tags.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -231,6 +246,13 @@ class AssetResponseDto {
json[r'smartInfo'] = this.smartInfo;
} else {
// json[r'smartInfo'] = null;
}
json[r'stack'] = this.stack;
json[r'stackCount'] = this.stackCount;
if (this.stackParentId != null) {
json[r'stackParentId'] = this.stackParentId;
} else {
// json[r'stackParentId'] = null;
}
json[r'tags'] = this.tags;
if (this.thumbhash != null) {
@ -276,6 +298,9 @@ class AssetResponseDto {
people: PersonResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType<bool>(json, r'resized')!,
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
stack: AssetResponseDto.listFromJson(json[r'stack']),
stackCount: mapValueOfType<int>(json, r'stackCount')!,
stackParentId: mapValueOfType<String>(json, r'stackParentId'),
tags: TagResponseDto.listFromJson(json[r'tags']),
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
@ -347,6 +372,7 @@ class AssetResponseDto {
'originalPath',
'ownerId',
'resized',
'stackCount',
'thumbhash',
'type',
'updatedAt',

View file

@ -15,6 +15,7 @@ class SharedLinkEditDto {
SharedLinkEditDto({
this.allowDownload,
this.allowUpload,
this.changeExpiryTime,
this.description,
this.expiresAt,
this.showMetadata,
@ -36,6 +37,15 @@ class SharedLinkEditDto {
///
bool? allowUpload;
/// Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? changeExpiryTime;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@ -58,6 +68,7 @@ class SharedLinkEditDto {
bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.changeExpiryTime == changeExpiryTime &&
other.description == description &&
other.expiresAt == expiresAt &&
other.showMetadata == showMetadata;
@ -67,12 +78,13 @@ class SharedLinkEditDto {
// ignore: unnecessary_parenthesis
(allowDownload == null ? 0 : allowDownload!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) +
(changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(showMetadata == null ? 0 : showMetadata!.hashCode);
@override
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -86,6 +98,11 @@ class SharedLinkEditDto {
} else {
// json[r'allowUpload'] = null;
}
if (this.changeExpiryTime != null) {
json[r'changeExpiryTime'] = this.changeExpiryTime;
} else {
// json[r'changeExpiryTime'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
@ -114,6 +131,7 @@ class SharedLinkEditDto {
return SharedLinkEditDto(
allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
changeExpiryTime: mapValueOfType<bool>(json, r'changeExpiryTime'),
description: mapValueOfType<String>(json, r'description'),
expiresAt: mapDateTime(json, r'expiresAt', ''),
showMetadata: mapValueOfType<bool>(json, r'showMetadata'),

View file

@ -0,0 +1,106 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UpdateStackParentDto {
/// Returns a new [UpdateStackParentDto] instance.
UpdateStackParentDto({
required this.newParentId,
required this.oldParentId,
});
String newParentId;
String oldParentId;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto &&
other.newParentId == newParentId &&
other.oldParentId == oldParentId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(newParentId.hashCode) +
(oldParentId.hashCode);
@override
String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'newParentId'] = this.newParentId;
json[r'oldParentId'] = this.oldParentId;
return json;
}
/// Returns a new [UpdateStackParentDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateStackParentDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateStackParentDto(
newParentId: mapValueOfType<String>(json, r'newParentId')!,
oldParentId: mapValueOfType<String>(json, r'oldParentId')!,
);
}
return null;
}
static List<UpdateStackParentDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateStackParentDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateStackParentDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateStackParentDto> mapFromJson(dynamic json) {
final map = <String, UpdateStackParentDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateStackParentDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateStackParentDto-objects as value to a dart map
static Map<String, List<UpdateStackParentDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateStackParentDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'newParentId',
'oldParentId',
};
}

View file

@ -174,6 +174,11 @@ void main() {
// TODO
});
//Future updateStackParent(UpdateStackParentDto updateStackParentDto) async
test('test updateStackParent', () async {
// TODO
});
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
test('test uploadFile', () async {
// TODO

View file

@ -31,6 +31,16 @@ void main() {
// TODO
});
// bool removeParent
test('to test the property `removeParent`', () async {
// TODO
});
// String stackParentId
test('to test the property `stackParentId`', () async {
// TODO
});
});

View file

@ -142,6 +142,21 @@ void main() {
// TODO
});
// List<AssetResponseDto> stack (default value: const [])
test('to test the property `stack`', () async {
// TODO
});
// int stackCount
test('to test the property `stackCount`', () async {
// TODO
});
// String stackParentId
test('to test the property `stackParentId`', () async {
// TODO
});
// List<TagResponseDto> tags (default value: const [])
test('to test the property `tags`', () async {
// TODO

View file

@ -26,6 +26,12 @@ void main() {
// TODO
});
// Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
// bool changeExpiryTime
test('to test the property `changeExpiryTime`', () async {
// TODO
});
// String description
test('to test the property `description`', () async {
// TODO

View file

@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for UpdateStackParentDto
void main() {
// final instance = UpdateStackParentDto();
group('test UpdateStackParentDto', () {
// String newParentId
test('to test the property `newParentId`', () async {
// TODO
});
// String oldParentId
test('to test the property `oldParentId`', () async {
// TODO
});
});
}

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.82.0+106
version: 1.82.1+106
isar_version: &isar_version 3.1.0+1
environment:

View file

@ -25,6 +25,7 @@ void main() {
isFavorite: false,
isArchived: false,
isTrashed: false,
stackCount: 0,
),
);
}

View file

@ -35,6 +35,7 @@ void main() {
isFavorite: false,
isArchived: false,
isTrashed: false,
stackCount: 0,
);
}

View file

@ -1673,6 +1673,41 @@
]
}
},
"/asset/stack/parent": {
"put": {
"operationId": "updateStackParent",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStackParentDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/statistics": {
"get": {
"operationId": "getAssetStats",
@ -5379,7 +5414,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.82.0",
"version": "1.82.1",
"contact": {}
},
"tags": [],
@ -5696,6 +5731,13 @@
},
"isFavorite": {
"type": "boolean"
},
"removeParent": {
"type": "boolean"
},
"stackParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
@ -5941,6 +5983,19 @@
"smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto"
},
"stack": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
},
"stackCount": {
"type": "integer"
},
"stackParentId": {
"nullable": true,
"type": "string"
},
"tags": {
"items": {
"$ref": "#/components/schemas/TagResponseDto"
@ -5961,6 +6016,7 @@
},
"required": [
"type",
"stackCount",
"deviceAssetId",
"deviceId",
"ownerId",
@ -7846,6 +7902,10 @@
"allowUpload": {
"type": "boolean"
},
"changeExpiryTime": {
"description": "Few clients cannot send null to set the expiryTime to never.\nSetting this flag and not sending expiryAt is considered as null instead.\nClients that can send null values can ignore this.",
"type": "boolean"
},
"description": {
"type": "string"
},
@ -8521,6 +8581,23 @@
},
"type": "object"
},
"UpdateStackParentDto": {
"properties": {
"newParentId": {
"format": "uuid",
"type": "string"
},
"oldParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"oldParentId",
"newParentId"
],
"type": "object"
},
"UpdateTagDto": {
"properties": {
"name": {

View file

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.82.0",
"version": "1.82.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.82.0",
"version": "1.82.1",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.22.11",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.82.0",
"version": "1.82.1",
"description": "",
"author": "",
"private": true,
@ -26,7 +26,7 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand --forceExit",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand",
"typeorm": "typeorm",
"typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",

View file

@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import {
albumStub,
assetStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
@ -225,7 +224,7 @@ describe(AlbumService.name, () => {
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.hasAsset).toHaveBeenCalledWith(albumStub.oneAsset.id, 'not-in-album');
expect(albumMock.hasAsset).toHaveBeenCalledWith({ albumId: 'album-4', assetId: 'not-in-album' });
expect(albumMock.update).not.toHaveBeenCalled();
});
@ -461,6 +460,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -473,9 +473,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
});
it('should not set the thumbnail if the album has one already', async () => {
@ -490,9 +493,9 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-id',
});
expect(albumMock.addAssets).toHaveBeenCalled();
});
it('should allow a shared user to add assets', async () => {
@ -512,9 +515,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
});
it('should allow a shared link user to add assets', async () => {
@ -523,6 +529,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -535,9 +542,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
@ -550,6 +560,7 @@ describe(AlbumService.name, () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
@ -558,10 +569,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1',
});
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
});
@ -569,6 +578,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
@ -620,17 +630,14 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [],
albumThumbnailAssetId: null,
});
expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
expect(albumMock.removeAssets).toHaveBeenCalledWith({ assetIds: ['asset-id'], albumId: 'album-123' });
});
it('should skip assets not in the album', async () => {
@ -647,9 +654,14 @@ describe(AlbumService.name, () => {
it('should skip assets without user permission to remove', async () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION },
{
success: false,
id: 'asset-id',
error: BulkIdErrorReason.NO_PERMISSION,
},
]);
expect(albumMock.update).not.toHaveBeenCalled();
@ -658,6 +670,7 @@ describe(AlbumService.name, () => {
it('should reset the thumbnail if it is removed', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
@ -666,9 +679,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.withLocation],
albumThumbnailAssetId: assetStub.withLocation.id,
});
expect(albumMock.updateThumbnails).toHaveBeenCalled();
});
});

View file

@ -120,7 +120,7 @@ export class AlbumService {
const album = await this.findOrFail(id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
const valid = await this.albumRepository.hasAsset({ albumId: id, assetId: dto.albumThumbnailAssetId });
if (!valid) {
throw new BadRequestException('Invalid album thumbnail');
}
@ -148,35 +148,34 @@ export class AlbumService {
}
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id);
for (const assetId of dto.ids) {
const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id);
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id, success: true });
album.assets.push({ id } as AssetEntity);
results.push({ id: assetId, success: true });
}
const newAsset = results.find(({ success }) => success);
if (newAsset) {
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAssetIds.length > 0) {
await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds });
await this.albumRepository.update({
id,
assets: album.assets,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id,
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAssetIds[0],
});
}
@ -184,42 +183,37 @@ export class AlbumService {
}
async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id);
for (const assetId of dto.ids) {
const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (!hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = await this.access.hasAny(authUser, [
{ permission: Permission.ALBUM_REMOVE_ASSET, id },
{ permission: Permission.ASSET_SHARE, id },
{ permission: Permission.ALBUM_REMOVE_ASSET, id: assetId },
{ permission: Permission.ASSET_SHARE, id: assetId },
]);
if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id, success: true });
album.assets = album.assets.filter((asset) => asset.id !== id);
if (album.albumThumbnailAssetId === id) {
album.albumThumbnailAssetId = null;
}
results.push({ id: assetId, success: true });
}
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.albumRepository.update({
id,
assets: album.assets,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null,
});
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0) {
await this.albumRepository.removeAssets({ albumId: id, assetIds: removedIds });
await this.albumRepository.update({ id, updatedAt: new Date() });
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
}
}
return results;

View file

@ -20,6 +20,7 @@ import { Readable } from 'stream';
import { JobName } from '../job';
import {
AssetStats,
CommunicationEvent,
IAssetRepository,
ICommunicationRepository,
ICryptoRepository,
@ -636,10 +637,89 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
});
/// Stack related
it('should require asset update access for parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
await expect(
sut.updateAll(authStub.user1, {
ids: ['asset-1'],
stackParentId: 'parent',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should update parent asset when children are added', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
ids: [],
stackParentId: 'parent',
}),
expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
});
it('should update parent asset when children are removed', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
await sut.updateAll(authStub.user1, {
ids: ['child-1'],
removeParent: true,
}),
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
});
it('update parentId for new children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
stackParentId: 'parent',
ids: ['child-1', 'child-2'],
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
});
it('nullify parentId for remove children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
removeParent: true,
ids: ['child-1', 'child-2'],
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
});
it('merge stacks if new child has children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
]);
await sut.updateAll(authStub.user1, {
ids: ['child-1'],
stackParentId: 'parent',
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
});
it('should send ws asset update event', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
ids: ['asset-1'],
stackParentId: 'parent',
});
expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
'asset-1',
]);
});
});
describe('deleteAll', () => {
it('should required asset delete access for all ids', async () => {
it('should require asset delete access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@ -677,7 +757,7 @@ describe(AssetService.name, () => {
});
describe('restoreAll', () => {
it('should required asset restore access for all ids', async () => {
it('should require asset restore access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@ -757,6 +837,21 @@ describe(AssetService.name, () => {
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
});
it('should update stack parent if asset has stack children', async () => {
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
stackParentId: 'stack-child-asset-1',
});
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
stackParentId: null,
});
});
it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id)
@ -854,4 +949,70 @@ describe(AssetService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
});
});
describe('updateStackParent', () => {
it('should require asset update access for new parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.updateStackParent(authStub.user1, {
oldParentId: 'old',
newParentId: 'new',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require asset read access for old parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
await expect(
sut.updateStackParent(authStub.user1, {
oldParentId: 'old',
newParentId: 'new',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('make old parent the child of new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getById)
.calledWith(assetStub.image.id)
.mockResolvedValue(assetStub.image as AssetEntity);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.image.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
});
it('remove stackParentId of new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
});
it('update stackParentId of old parents children to new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith(
[assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
{ stackParentId: 'new' },
);
});
});
});

View file

@ -40,6 +40,7 @@ import {
TimeBucketDto,
TrashAction,
UpdateAssetDto,
UpdateStackParentDto,
mapStats,
} from './dto';
import {
@ -208,7 +209,7 @@ export class AssetService {
if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset));
} else {
return assets.map((asset) => mapAsset(asset, true));
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
}
@ -338,10 +339,29 @@ export class AssetService {
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, ...options } = dto;
const { ids, removeParent, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
if (removeParent) {
(options as Partial<AssetEntity>).stackParentId = null;
const assets = await this.assetRepository.getByIds(ids);
// This updates the updatedAt column of the parents to indicate that one of its children is removed
// All the unique parent's -> parent is set to null
ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
} else if (options.stackParentId) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
// Merge stacks
const assets = await this.assetRepository.getByIds(ids);
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
// This updates the updatedAt column of the parent to indicate that a new child has been added
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
}
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options);
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
}
async handleAssetDeletionCheck() {
@ -384,6 +404,14 @@ export class AssetService {
);
}
// Replace the parent of the stack children with a new asset
if (asset.stack && asset.stack.length != 0) {
const stackIds = asset.stack.map((a) => a.id);
const newParentId = stackIds[0];
await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
await this.assetRepository.updateAll([newParentId], { stackParentId: null });
}
await this.assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
@ -454,6 +482,25 @@ export class AssetService {
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
}
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
const { oldParentId, newParentId } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
const childIds: string[] = [];
const oldParent = await this.assetRepository.getById(oldParentId);
if (oldParent != null) {
childIds.push(oldParent.id);
// Get all children of old parent
childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
}
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
// Remove ParentId of new parent if this was previously a child of some other asset
return this.assetRepository.updateAll([newParentId], { stackParentId: null });
}
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);

View file

@ -0,0 +1,9 @@
import { ValidateUUID } from '../../domain.util';
export class UpdateStackParentDto {
@ValidateUUID()
oldParentId!: string;
@ValidateUUID()
newParentId!: string;
}

View file

@ -1,6 +1,6 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional } from '../../domain.util';
import { Optional, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto {
@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional()
@IsBoolean()
isArchived?: boolean;
@Optional()
@ValidateUUID()
stackParentId?: string;
@Optional()
@IsBoolean()
removeParent?: boolean;
}
export class UpdateAssetDto {

View file

@ -1,4 +1,5 @@
export * from './asset-ids.dto';
export * from './asset-stack.dto';
export * from './asset-statistics.dto';
export * from './asset.dto';
export * from './download.dto';

View file

@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
people?: PersonResponseDto[];
/**base64 encoded sha1 hash */
checksum!: string;
stackParentId?: string | null;
stack?: AssetResponseDto[];
@ApiProperty({ type: 'integer' })
stackCount!: number;
}
export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
export type AssetMapOptions = {
stripMetadata?: boolean;
withStack?: boolean;
};
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
stackParentId: entity.stackParentId,
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
stackCount: entity.stack?.length ?? 0,
isExternal: entity.isExternal,
isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly,

View file

@ -409,6 +409,54 @@ describe(MetadataService.name, () => {
localDateTime: new Date('1970-01-01'),
});
});
it('should handle duration', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
expect(assetMock.upsertExif).toHaveBeenCalled();
expect(assetMock.save).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
duration: '00:00:06.210',
}),
);
});
it('should handle duration as an object without Scale', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
expect(assetMock.upsertExif).toHaveBeenCalled();
expect(assetMock.save).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
duration: '00:00:06.200',
}),
);
});
it('should handle duration with scale', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
expect(assetMock.upsertExif).toHaveBeenCalled();
expect(assetMock.save).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
duration: '00:00:06.207',
}),
);
});
});
describe('handleQueueSidecar', () => {

View file

@ -7,6 +7,7 @@ import { Duration } from 'luxon';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import {
ExifDuration,
IAlbumRepository,
IAssetRepository,
ICryptoRepository,
@ -109,6 +110,10 @@ export class MetadataService {
}
}
async teardown() {
await this.repository.teardown();
}
async handleLivePhotoLinking(job: IEntityJob) {
const { id } = job;
const [asset] = await this.assetRepository.getByIds([id]);
@ -394,7 +399,11 @@ export class MetadataService {
return bitsPerSample;
}
private getDuration(seconds?: number): string {
return Duration.fromObject({ seconds }).toFormat('hh:mm:ss.SSS');
private getDuration(seconds?: number | ExifDuration): string {
let _seconds = seconds as number;
if (typeof seconds === 'object') {
_seconds = seconds.Value * (seconds?.Scale || 1);
}
return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS');
}
}

View file

@ -11,13 +11,24 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
export interface AlbumAsset {
albumId: string;
assetId: string;
}
export interface AlbumAssets {
albumId: string;
assetIds: string[];
}
export interface IAlbumRepository {
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
hasAsset(id: string, assetId: string): Promise<boolean>;
/** Remove an asset from _all_ albums */
removeAsset(id: string): Promise<void>;
addAssets(assets: AlbumAssets): Promise<void>;
hasAsset(asset: AlbumAsset): Promise<boolean>;
removeAsset(assetId: string): Promise<void>;
removeAssets(assets: AlbumAssets): Promise<void>;
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>;

View file

@ -4,6 +4,7 @@ export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash',
ASSET_UPDATE = 'on_asset_update',
ASSET_RESTORE = 'on_asset_restore',
PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version',

View file

@ -14,7 +14,12 @@ export interface ReverseGeocodeResult {
city: string | null;
}
export interface ImmichTags extends Omit<Tags, 'FocalLength'> {
export interface ExifDuration {
Value: number;
Scale?: number;
}
export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
ContentIdentifier?: string;
MotionPhoto?: number;
MotionPhotoVersion?: number;
@ -22,10 +27,12 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength'> {
MediaGroupUUID?: string;
ImagePixelDepth?: string;
FocalLength?: number;
Duration?: number | ExifDuration;
}
export interface IMetadataRepository {
init(options: Partial<InitOptions>): Promise<void>;
teardown(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
deleteCache(): Promise<void>;
getExifTags(path: string): Promise<ImmichTags | null>;

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