chore: pull main
This commit is contained in:
commit
16646d9946
152 changed files with 4494 additions and 617 deletions
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -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
|
||||
|
|
7
Makefile
7
Makefile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
113
README_it_IT.md
Normal 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
|
|
@ -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>
|
||||
|
||||
## 免責事項
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
145
cli/src/api/open-api/api.ts
generated
145
cli/src/api/open-api/api.ts
generated
|
@ -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.
|
||||
|
|
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
|
@ -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).
|
||||
|
|
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
||||
|
|
|
@ -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
98
docs/static/img/immich-logo.svg
vendored
Normal 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 |
|
@ -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"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: () =>
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
);
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
47
mobile/lib/modules/home/models/selection_state.dart
Normal file
47
mobile/lib/modules/home/models/selection_state.dart
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()),
|
||||
],
|
||||
|
|
107
mobile/lib/modules/shared_link/models/shared_link.dart
Normal file
107
mobile/lib/modules/shared_link/models/shared_link.dart
Normal 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;
|
||||
}
|
|
@ -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),
|
||||
);
|
||||
});
|
115
mobile/lib/modules/shared_link/services/shared_link.service.dart
Normal file
115
mobile/lib/modules/shared_link/services/shared_link.service.dart
Normal 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;
|
||||
}
|
||||
}
|
307
mobile/lib/modules/shared_link/ui/shared_link_item.dart
Normal file
307
mobile/lib/modules/shared_link/ui/shared_link_item.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
459
mobile/lib/modules/shared_link/views/shared_link_edit_page.dart
Normal file
459
mobile/lib/modules/shared_link/views/shared_link_edit_page.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
126
mobile/lib/modules/shared_link/views/shared_link_page.dart
Normal file
126
mobile/lib/modules/shared_link/views/shared_link_page.dart
Normal 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()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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()}");
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}";
|
||||
}
|
||||
|
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
|
@ -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
|
||||
|
|
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
|
@ -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)
|
||||
|
|
55
mobile/openapi/doc/AssetApi.md
generated
55
mobile/openapi/doc/AssetApi.md
generated
|
@ -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)
|
||||
|
||||
|
|
2
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
2
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
|
@ -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)
|
||||
|
||||
|
|
3
mobile/openapi/doc/AssetResponseDto.md
generated
3
mobile/openapi/doc/AssetResponseDto.md
generated
|
@ -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) | |
|
||||
|
|
1
mobile/openapi/doc/SharedLinkEditDto.md
generated
1
mobile/openapi/doc/SharedLinkEditDto.md
generated
|
@ -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]
|
||||
|
|
16
mobile/openapi/doc/UpdateStackParentDto.md
generated
Normal file
16
mobile/openapi/doc/UpdateStackParentDto.md
generated
Normal 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)
|
||||
|
||||
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
@ -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';
|
||||
|
|
39
mobile/openapi/lib/api/asset_api.dart
generated
39
mobile/openapi/lib/api/asset_api.dart
generated
|
@ -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:
|
||||
///
|
||||
|
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
@ -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':
|
||||
|
|
40
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
40
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
|
@ -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;
|
||||
|
|
28
mobile/openapi/lib/model/asset_response_dto.dart
generated
28
mobile/openapi/lib/model/asset_response_dto.dart
generated
|
@ -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',
|
||||
|
|
20
mobile/openapi/lib/model/shared_link_edit_dto.dart
generated
20
mobile/openapi/lib/model/shared_link_edit_dto.dart
generated
|
@ -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'),
|
||||
|
|
106
mobile/openapi/lib/model/update_stack_parent_dto.dart
generated
Normal file
106
mobile/openapi/lib/model/update_stack_parent_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
5
mobile/openapi/test/asset_api_test.dart
generated
5
mobile/openapi/test/asset_api_test.dart
generated
|
@ -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
|
||||
|
|
10
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
10
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
|
@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
|
15
mobile/openapi/test/asset_response_dto_test.dart
generated
15
mobile/openapi/test/asset_response_dto_test.dart
generated
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
32
mobile/openapi/test/update_stack_parent_dto_test.dart
generated
Normal file
32
mobile/openapi/test/update_stack_parent_dto_test.dart
generated
Normal 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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -25,6 +25,7 @@ void main() {
|
|||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
stackCount: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ void main() {
|
|||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
stackCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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": {
|
||||
|
|
4
server/package-lock.json
generated
4
server/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
9
server/src/domain/asset/dto/asset-stack.dto.ts
Normal file
9
server/src/domain/asset/dto/asset-stack.dto.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { ValidateUUID } from '../../domain.util';
|
||||
|
||||
export class UpdateStackParentDto {
|
||||
@ValidateUUID()
|
||||
oldParentId!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
newParentId!: string;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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[]>;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue