Compare commits

..

58 Commits

Author SHA1 Message Date
Alex The Bot
cc1fecfffd Version v1.75.1 2023-08-26 18:31:14 +00:00
Alex
e02817362c fix(server): initialization search service (#3879) 2023-08-26 13:29:34 -05:00
shalong-tanwen
1b0484fc46 fix(mobile): Widget overflow due to exception on logout (#3869) 2023-08-26 00:06:55 -05:00
Alex The Bot
6fe214a784 Version v1.75.0 2023-08-26 04:44:39 +00:00
Alex
e18a9f84a4 feat(web): slideshow mode (#3813)
* slideshow

slideshow for main screen

Added control buttons

update

close detail panel window sif opened

format

5 seconds

remove unused files

handle video player

format

* fix: restrict slideshow to timeline views

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-25 18:20:45 -05:00
Daniel Dietzler
59bb727636 feat(web, server): Ability to use config file instead of admin UI (#3836)
* implement method to read config file

* getConfig returns config file if present

* return isConfigFile for http requests

* disable elements if config file is used, show message if config file is set, copy existing config to clipboard

* fix allowing partial configuration files

* add new env variable to docs

* fix tests

* minor refactoring, address review

* adapt config type in frontend

* remove unnecessary imports

* move config file reading to system-config repo

* add documentation

* fix code formatting in system settings page

* add validator for config file

* fix formatting in docs

* update generated files

* throw error when trying to update config. e.g. via cli or api

* switch to feature flags for isConfigFile

* refactoring

* refactor: config file

* chore: open api

* feat: always show copy/export buttons

* fix: default flags

* refactor: copy to clipboard

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-25 13:44:52 -04:00
Le_Futuriste
20e0c03b39 feat(web): add link to external map in leaflet popup (#3847)
* feat(web): add link to external map in leaflet popup

Sometimes it's useful to open a geo location to an external map
application to not have to copy the coordinates manually.
Here I put a link to OpenStreetMap because it's what I personally use.
But I known some people would want to use something different. We could
instead link to geohacks (eg. https://geohack.toolforge.org/geohack.php?params=048.861085_N_0002.313158_E_globe:Earth)
or make it a configurable param.

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-25 13:19:49 +00:00
waclaw66
6d1567cf44 smaller album title (#3860) 2023-08-25 14:10:08 +02:00
waclaw66
dc3f53a973 album and face menu dots visible on hover only (#3859) 2023-08-25 06:35:52 -05:00
waclaw66
dad7cf47b4 fix(web): delete album consolidation (#3858) 2023-08-25 13:03:16 +02:00
Mert
165b91b068 feat(ml)!: switch image classification and CLIP models to ONNX (#3809) 2023-08-25 06:28:51 +02:00
Jason Rasmussen
8211afb726 feat(web,server)!: configure machine learning via the UI (#3768) 2023-08-25 06:15:03 +02:00
James58899
2cccef174a fix(mobile): missing conversion to UTC time zone (#3495) 2023-08-25 06:08:19 +02:00
Jason Rasmussen
9bbef4a97b refactor(web): shared link key auth (#3855) 2023-08-25 06:03:28 +02:00
Jason Rasmussen
10c2bda3a9 chore: remove without thumbs (#3529)
* refactor(server): remove withoutThumbs

* chore: open api

* fix: bad merge
2023-08-24 21:45:54 -04:00
Fynn Petersen-Frey
cf9e04c8ec feat(server): asset entity audit (#3824)
* feat(server): audit log

* feedback

* Insert to database

* migration

* test

* controller/repository/service

* test

* module

* feat(server): implement audit endpoint

* directly return changed assets

* add daily cleanup of audit table

* fix tests

* review feedback

* ci

* refactor(server): audit implementation

* chore: open api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-24 15:28:50 -04:00
Daniele Ricci
d6887117ac chore(web): improve drop shadow on three-dots icon (#3835) 2023-08-23 07:20:50 +02:00
Alex
3b11be2859 fix(web): cannot view publlic shared album (#3829) 2023-08-22 08:05:48 +02:00
Alex
d7f52739e8 fix(web): shared link return 404 (#3791) 2023-08-22 07:22:49 +02:00
waclaw66
71ea46d95e fix(web): merge face thumbnail (#3822) 2023-08-22 04:34:53 +02:00
Daniele Ricci
e2afc43506 Use proper text/drop shadow on tree-dots icon and face name (#3800) 2023-08-20 18:36:31 -05:00
waclaw66
6aed1180e7 fix(web): album list padding (#3790) 2023-08-20 18:30:52 -05:00
Flyot
476b735e3c fix(web): ContextMenu unsatisfying UI behaviors (#3787) 2023-08-20 18:28:25 -05:00
Mert
7ad12c7f33 use camera wb for raw (#3806) 2023-08-20 18:26:01 -05:00
Mert
60729a091a make lazy loading default (#3797) 2023-08-20 18:24:14 -05:00
Alex The Bot
d2bad1d553 Version v1.74.0 2023-08-19 06:09:16 +00:00
Jason Rasmussen
3e31ad51be feat: shared link album time buckets (#3776) 2023-08-18 22:19:42 -05:00
Jason Rasmussen
fbeb4664f7 feat(web): archive from album (#3773) 2023-08-18 17:55:06 -05:00
Steffen Auer
4ee8a30a5a chore(mobile): ios map launch, use OSM as map fallback, use dates as labels (#3772) 2023-08-18 17:53:50 -05:00
martyfuhry
6243bce46c chore(mobile): Bump to Flutter 3.13 (#3767)
* Bump to Flutter 3.13.0

* Updates permission status

* Adds hidden to app livecycle state

* Updates and switches to WakelockPlus

* bump flutter version github action

* mobile test version

* fix format

* video player

* video uri

* ios test

* Update android target sdk requirement to PlayStore

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-18 17:52:40 -05:00
Daniele Ricci
98b72fdb9b feat: set person birth date (web only) (#3721)
* Person birth date (data layer)

* Person birth date (data layer)

* Person birth date (service layer)

* Person birth date (service layer, API)

* Person birth date (service layer, API)

* Person birth date (UI) (wip)

* Person birth date (UI) (wip)

* Person birth date (UI) (wip)

* Person birth date (UI) (wip)

* UI: Use "date of birth" everywhere

* UI: better modal dialog

Similar to the API key modal.

* UI: set date of birth from people page

* Use typed events for modal dispatcher

* Date of birth tests (wip)

* Regenerate API

* Code formatting

* Fix Svelte typing

* Fix Svelte typing

* Fix person model [skip ci]

* Minor refactoring [skip ci]

* Typed event dispatcher [skip ci]

* Refactor typed event dispatcher [skip ci]

* Fix unchanged birthdate check [skip ci]

* Remove unnecessary custom transformer [skip ci]

* PersonUpdate: call search index update job only when needed

* Regenerate API

* Code formatting

* Fix tests

* Fix DTO

* Regenerate API

* chore: verbiage and view mode

* feat: show current age

* test: person e2e

* fix: show name for birth date selection

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-18 16:10:29 -04:00
Jason Rasmussen
5e901e4d21 feat(web,server): run jobs for specific assets (#3712)
* feat(web,server): manually queue asset job

* chore: open api

* chore: tests
2023-08-18 09:31:48 -05:00
Craeckie
66490d5db4 chore: Enable logging, but reduce verboseness of typesense container (#3761)
Co-authored-by: ultrabook <ultrabook>
2023-08-18 09:25:52 -05:00
Jason Rasmussen
2b839088c7 feat(web,server): server features (#3756)
* feat: server features

* chore: open api

* icon size

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-18 04:55:26 +00:00
Alex
28d3d3e679 fix(mobile): invalid range on label builder crash timeline (#3759)
* fix(mobile): invalid date on label builder crash timeline

* actual fix

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-08-17 23:50:41 -05:00
Alex
2de30e34f4 feat(mobile): Improve album UI and Interactions (#3754)
* fix: outlick editable field does not change edit icon

* fix: unfocus on submit change album name

* styling

* styling

* confirm dialog

* Confirm deletion

* render user

* user avatar with image

* use UserCircleAvatar

* rights

* stlying

* remove/leave options

* styling

* state management

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-08-17 23:26:12 -05:00
Jason Rasmussen
2ff71b0d27 fix(web): play videos on safari (#3748)
* fix(web): play videos on safari

* autoplay

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-17 13:52:50 -05:00
Jason Rasmussen
cdb45364c3 feat(server): add support for the tif extension (#3743) 2023-08-17 10:27:29 -05:00
Jason Rasmussen
8ba338fbe1 refactor(web): harden video can play method (#3745) 2023-08-17 10:02:12 -05:00
Kevin
ce84f9c755 feat(web): album list options (#3667)
* Album view option for cover or list view

* Dropdown can now receive list of icons to display with selected option

* Formatting

* Use table element with formatting similar to other pages

* Make table rows clickable with hover styling

* Also make row navigateable using keyboard without mouse

* Formatting

* Define DropdownOption interface

* Album view mode type definition for typescript support in if statements

* format

* fix typing

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-17 08:46:39 -05:00
shalong-tanwen
d1e74a28d9 fix(mobile): LivePhoto video not uploaded during manual asset upload (#3732) 2023-08-17 07:29:49 -05:00
martin
78a2a9e666 refactor(web): user-settings (#3700)
* refactor(web): user-settings

* feat: move the logic to the server

* use const

* fix: error 403

* fix: remove console.log
2023-08-16 22:56:06 -05:00
Lucas Eduardo
53f5643994 fix: shebangs (#3643)
Signed-off-by: lucasew <lucas59356@gmail.com>
2023-08-16 22:50:01 -05:00
Daniele Ricci
4ee634766d fix(web): label for attribute (#3731) 2023-08-16 16:09:38 -05:00
Jason Rasmussen
bab739efbd restore: bulk actions (#3730)
* feat: improve bulk isArchive and isFavorite updates

* chore: open api
2023-08-16 15:04:55 -05:00
Daniele Ricci
8568ec838a fix(web): Fix label for attribute (#3726) 2023-08-16 13:27:57 -05:00
Jason Rasmussen
4cbb18aabc feat(web): remove and delete from album (#3725) 2023-08-16 13:25:39 -05:00
Daniele Ricci
3fb60aca4f chore(web): better explain what the thumbnails type are for (#3724) 2023-08-16 13:25:07 -05:00
Skyler Mäntysaari
19bbdebdf7 fix(mobile): Do not show version announcement if user is not admin. (#3703) 2023-08-15 21:12:49 -05:00
martin
bc66b1a556 fix(web): user-management layout (#3704)
* fix: user-management layout

* better user form scrollbar

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-16 01:46:23 +00:00
Jason Rasmussen
4762fd83d4 fix(server): link live photos after metadata extraction finishes (#3702)
* fix(server): link live photos after metadata extraction finishes

* chore: fix test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-08-15 20:34:57 -05:00
martin
c27c12d975 fix(server): people sorting (#3713) 2023-08-15 19:06:49 -05:00
Jason Rasmussen
0abbd85134 fix(web,server): album share performance (#3698) 2023-08-15 13:34:02 -05:00
Jason Rasmussen
af1f00dff9 chore(server): cleanup (#3699) 2023-08-15 11:05:32 -05:00
Vantao
35b4c9d375 doc: update README_zh_CN.md (#3701) 2023-08-15 16:05:00 +00:00
Sergey Kondrikov
74da15e20d fix(web,server): disable partner's archive access (#3695) 2023-08-15 11:02:38 -05:00
Jason Rasmussen
efc7fdb669 fix(web,server): use POST request to get download info (#3694)
* fix(web,server): use POST request to get download info

* chore: open api
2023-08-15 10:49:32 -05:00
Alex
a75f368d5b chore: post update 2023-08-15 09:42:28 -05:00
360 changed files with 9266 additions and 2729 deletions

View File

@@ -45,7 +45,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.10.5" flutter-version: "3.13.0"
cache: true cache: true
- name: Create the Keystore - name: Create the Keystore

View File

@@ -23,7 +23,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.10.5" flutter-version: "3.13.0"
- name: Install dependencies - name: Install dependencies
run: dart pub get run: dart pub get

View File

@@ -149,7 +149,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.10.5" flutter-version: "3.13.0"
- name: Run tests - name: Run tests
working-directory: ./mobile working-directory: ./mobile
run: flutter test -j 1 run: flutter test -j 1
@@ -171,6 +171,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install --with dev poetry install --with dev
poetry run pip install --no-deps -r requirements.txt
- name: Lint with ruff - name: Lint with ruff
run: | run: |
poetry run ruff check --format=github app poetry run ruff check --format=github app

View File

@@ -13,7 +13,7 @@
</p> </p>
<h3 align="center">Immich - 高性能的自托管照片和视频备份方案</h3> <h3 align="center">Immich - 高性能的自托管照片和视频备份方案</h3>
<p align="center"> <p align="center">
请注意: 此README不是由Immich团队维护, 这意味着它在某一时间点不会被更新,因为我们是依靠贡献者来更新的。感谢理解。 请注意: 此 README 不是由 Immich 团队维护, 而是依靠贡献者来更新的,这意味着它可能并不会被及时更新。感谢理解。
</p> </p>
<br/> <br/>
<a href="https://immich.app"> <a href="https://immich.app">
@@ -31,29 +31,31 @@
## 免责声明 ## 免责声明
- ⚠️ 本项目正在 **非常活跃** 开发中。 - ⚠️ 本项目正在 **非常活跃** 开发中。
- ⚠️ 可能存在bug或者重大变更。 - ⚠️ 可能存在 bug 或者随时有重大变更。
- ⚠️ **不要把本软件作为存储照片或视频的唯一方式!** - ⚠️ **不要把本软件作为存储照片或视频的唯一方式**
- ⚠️ 为了您宝贵的照片与视频,始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
## 目录 ## 目录
- [官方文档](https://immich.app/docs/overview/introduction) - [官方文档](https://immich.app/docs)
- [路线图](https://github.com/orgs/immich-app/projects/1)
- [示例](#示例) - [示例](#示例)
- [功能特性](#功能特性) - [功能特性](#功能特性)
- [介绍](https://immich.app/docs/overview/introduction) - [介绍](https://immich.app/docs/overview/introduction)
- [安装](https://immich.app/docs/install/requirements) - [安装](https://immich.app/docs/install/requirements)
- [贡献指南](https://immich.app/docs/overview/support-the-project) - [贡献指南](https://immich.app/docs/overview/support-the-project)
- [支持本项目](#support-the-project) - [支持本项目](#支持本项目)
- [已知问题](#known-issues)
## 官方文档 ## 官方文档
可以在 https://immich.app/ 找到包含安装手册的官方文档. 可以在 https://immich.app/ 找到官方文档(包含安装手册)。
## 示例 ## 示例
可以在 https://demo.immich.app 访问示例. 可以在 https://demo.immich.app 访问示例
在移动端, 可以使用 `https://demo.immich.app/api`获取`服务终端链接` 在移动端, 可以使用 `https://demo.immich.app/api` 获取 `服务终端链接`
```bash title="示例认证信息" ```bash title="示例认证信息"
认证信息 认证信息
@@ -62,7 +64,7 @@
``` ```
``` ```
规格: 甲骨文免费虚拟机套餐-阿姆斯特丹 4核 2.4Ghz ARM64 CPU, 24GB RAM。 规格: 甲骨文免费虚拟机套餐——阿姆斯特丹 4核 2.4Ghz ARM64 CPU, 24GB RAM。
``` ```
# 功能特性 # 功能特性
@@ -78,41 +80,36 @@
| 共享相册 | 是 | 是 | | 共享相册 | 是 | 是 |
| 可拖动的快速导航栏 | 是 | 是 | | 可拖动的快速导航栏 | 是 | 是 |
| 支持RAW格式 (HEIC, HEIF, DNG, Apple ProRaw) | 是 | 是 | | 支持RAW格式 (HEIC, HEIF, DNG, Apple ProRaw) | 是 | 是 |
| 元数据视图 (EXIF, 地图) | 是 | 是 | | 元数据视图EXIF, 地图 | 是 | 是 |
| 通过元数据、对象和标签进行搜索 | 是 | No | | 通过元数据、对象和标签进行搜索 | 是 | |
| 管理功能 (用户管理) | N/A | 是 | | 管理功能用户管理 | | 是 |
| 后台备份 | Android | N/A | | 后台备份 | | N/A |
| 虚拟滚动 | 是 | 是 | | 虚拟滚动 | 是 | 是 |
| OAuth支持 | 是 | 是 | | OAuth 支持 | 是 | 是 |
| 实时照片备份和查看 (仅iOS) | 是 | 是 | | API Keys|N/A|是|
| 实况照片备份和查看 | 仅 iOS | 是 |
|用户自定义存储结构|是|是|
|公共分享|否|是|
|归档与收藏功能|是|是|
|全局地图|否|是|
|好友分享|是|是|
|人像识别与分组|是|是|
|回忆(那年今日)|是|是|
|离线支持|是|否|
|只读相册|是|是|
# 支持本项目 # 支持本项目
我已经致力于本项目并且将我会持续更新文档、新增功能和修复问题。但是我不能一个人走下去,所以我需要给予我下去的动力。 我已经致力于本项目并且将我会持续更新文档、新增功能和修复问题。但是独木不成林,我需要给予我坚持下去的动力。
就像我主页里面 [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) 说的一样,这是我和团队的一项艰巨任务。我希望某一天我能够全职开发本项目,在此我希望你们能够助我梦想成真。 就像我 [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) 节目里说的一样,这是我和团队的一项艰巨任务。并且我希望某一天我能够全职开发本项目,在此我请求您能够助我梦想成真。
如果使用了本项目一段时间,并且觉得上面的话有道理,那么请你按照如下方式帮助我吧。 如果使用了本项目一段时间,并且觉得上面的话有道理,那么请您考虑通过下列任一方式支持我吧。
## 捐赠 ## 捐赠
- [按月捐赠](https://github.com/sponsors/alextran1502) via GitHub Sponsors - 通过 GitHub Sponsors [按月捐赠](https://github.com/sponsors/alextran1502)
- [一次捐赠](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via Github Sponsors - 通过 Github Sponsors [次捐赠](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
- [Librepay](https://liberapay.com/alex.tran1502/)
# 已知问题 - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- 比特币: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
## TensorFlow 构建问题
_这是一个针对于Proxmox的已知问题_
TensorFlow 不能运行在很旧的CPU架构上, 需要运行在AVX和AVX2指令集的CPU上。如果你在docker-compose的命令行中遇到了 `illegal instruction core dump`的错误, 通过如下命令检查你的CPU flag寄存器然后确保你能够看到`AVX`和`AVX2`的字样:
```bash
more /proc/cpuinfo | grep flags
```
如果你在Proxmox中运行虚拟机, 虚拟机中没有启用flag寄存器。
你需要在虚拟机的硬件面板中把CPU类型从`kvm64`改为`host`。
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,14 @@ import { SessionService } from '../services/session.service';
import { LoginError } from '../cores/errors/login-error'; import { LoginError } from '../cores/errors/login-error';
import { exit } from 'node:process'; import { exit } from 'node:process';
import os from 'os'; import os from 'os';
import { ServerVersionReponseDto, UserResponseDto } from 'src/api/open-api'; import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
export abstract class BaseCommand { export abstract class BaseCommand {
protected sessionService!: SessionService; protected sessionService!: SessionService;
protected immichApi!: ImmichApi; protected immichApi!: ImmichApi;
protected deviceId!: string; protected deviceId!: string;
protected user!: UserResponseDto; protected user!: UserResponseDto;
protected serverVersion!: ServerVersionReponseDto; protected serverVersion!: ServerVersionResponseDto;
protected configDir; protected configDir;
protected authPath; protected authPath;

View File

@@ -100,8 +100,8 @@ services:
environment: environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data - TYPESENSE_DATA_DIR=/data
logging: # remove this to get debug messages
driver: none - GLOG_minloglevel=1
volumes: volumes:
- tsdata:/data - tsdata:/data

View File

@@ -68,8 +68,8 @@ services:
environment: environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data - TYPESENSE_DATA_DIR=/data
logging: # remove this to get debug messages
driver: none - GLOG_minloglevel=1
volumes: volumes:
- tsdata:/data - tsdata:/data
restart: always restart: always

View File

@@ -54,6 +54,8 @@ services:
environment: environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data - TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes: volumes:
- tsdata:/data - tsdata:/data
restart: always restart: always

View File

@@ -39,7 +39,7 @@ This often happens when using a reverse proxy or cloudflare tunnel in front of I
### Why is Immich slow on low-memory systems like the Raspberry Pi? ### Why is Immich slow on low-memory systems like the Raspberry Pi?
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file. Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_ENABLED=false` in your .env file.
### How to disable machine-learning and TypeSense? ### How to disable machine-learning and TypeSense?
@@ -47,7 +47,7 @@ Immich uses optional machine-learning features to enhance search results. This f
Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning. Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning.
::: :::
These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_URL=false` & `TYPESENSE_ENABLED=false` in your .env file. These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_ENABLED=false` & `TYPESENSE_ENABLED=false` in your .env file.
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)? ### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?

View File

@@ -0,0 +1,91 @@
# Config File
A config file can be provided as an alternative to the UI configuration.
### Step 1 - Create a new config file
In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich.
The default configuration looks like this:
```json
{
"ffmpeg": {
"crf": 23,
"threads": 0,
"preset": "ultrafast",
"targetVideoCodec": "h264",
"targetAudioCodec": "aac",
"targetResolution": "720",
"maxBitrate": "0",
"twoPass": false,
"transcode": "required",
"tonemap": "hable",
"accel": "disabled"
},
"job": {
"backgroundTask": {
"concurrency": 5
},
"clipEncoding": {
"concurrency": 2
},
"metadataExtraction": {
"concurrency": 5
},
"objectTagging": {
"concurrency": 2
},
"recognizeFaces": {
"concurrency": 2
},
"search": {
"concurrency": 5
},
"sidecar": {
"concurrency": 5
},
"storageTemplateMigration": {
"concurrency": 5
},
"thumbnailGeneration": {
"concurrency": 5
},
"videoConversion": {
"concurrency": 1
}
},
"oauth": {
"enabled": false,
"issuerUrl": "",
"clientId": "",
"clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile",
"storageLabelClaim": "preferred_username",
"buttonText": "Login with OAuth",
"autoRegister": true,
"autoLaunch": false
},
"passwordLogin": {
"enabled": true
},
"storageTemplate": {
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
},
"thumbnail": {
"webpSize": 250,
"jpegSize": 1440
}
}
```
:::tip
In Administration > Settings is a button to copy the current configuration to your clipboard.
So you can just grab it from there, paste it into a file and you're pretty much good to go.
:::
### Step 2 - Specify the file location
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.

View File

@@ -132,7 +132,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
IMMICH_WEB_URL=http://immich-web:3000 IMMICH_WEB_URL=http://immich-web:3000
IMMICH_SERVER_URL=http://immich-server:3001 IMMICH_SERVER_URL=http://immich-server:3001
IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
#################################################################################### ####################################################################################
# Alternative API's External Address - Optional # Alternative API's External Address - Optional

View File

@@ -1,3 +1,7 @@
---
sidebar_position: 90
---
# Environment Variables # Environment Variables
## Docker Compose ## Docker Compose
@@ -22,6 +26,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices | | `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices |
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices | | `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices |
| `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web | | `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web |
| `IMMICH_CONFIG_FILE` | Path to config file | | server |
:::tip :::tip
@@ -51,10 +56,11 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## URLs ## URLs
| Variable | Description | Default | Services | | Variable | Description | Default | Services |
| :---------------------------- | :------------------------------------------------------- | :-----------------------------------: | :-------------------- | | :-------------------------------- | :--------------------------- | :-----------------------------------: | :-------------------- |
| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy | | `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy |
| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy | | `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy |
| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, set `"false"` to disable ML | `http://immich-machine-learning:3003` | server, microservices | | `IMMICH_MACHINE_LEARNING_ENABLED` | Enabled machine learning | `true` | server, microservices |
| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, | `http://immich-machine-learning:3003` | server, microservices |
| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web | | `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web |
| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web | | `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web |

View File

@@ -1,5 +1,5 @@
--- ---
sidebar_position: 100 sidebar_position: 80
--- ---
import RegisterAdminUser from '../partials/_register-admin.md'; import RegisterAdminUser from '../partials/_register-admin.md';

View File

@@ -10,8 +10,9 @@ RUN poetry config installer.max-workers 10 && \
RUN python -m venv /opt/venv RUN python -m venv /opt/venv
ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}" ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml requirements.txt ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
RUN pip install --no-deps -r requirements.txt
FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
from pydantic import BaseSettings from pydantic import BaseSettings
@@ -8,25 +9,31 @@ from .schemas import ModelType
class Settings(BaseSettings): class Settings(BaseSettings):
cache_folder: str = "/cache" cache_folder: str = "/cache"
classification_model: str = "microsoft/resnet-50" classification_model: str = "microsoft/resnet-50"
clip_image_model: str = "clip-ViT-B-32" clip_image_model: str = "ViT-B-32::openai"
clip_text_model: str = "clip-ViT-B-32" clip_text_model: str = "ViT-B-32::openai"
facial_recognition_model: str = "buffalo_l" facial_recognition_model: str = "buffalo_l"
min_tag_score: float = 0.9 min_tag_score: float = 0.9
eager_startup: bool = True eager_startup: bool = False
model_ttl: int = 0 model_ttl: int = 0
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 3003 port: int = 3003
workers: int = 1 workers: int = 1
min_face_score: float = 0.7 min_face_score: float = 0.7
test_full: bool = False test_full: bool = False
request_threads: int = os.cpu_count() or 4
model_inter_op_threads: int = 1
model_intra_op_threads: int = 2
class Config: class Config:
env_prefix = "MACHINE_LEARNING_" env_prefix = "MACHINE_LEARNING_"
case_sensitive = False case_sensitive = False
_clean_name = str.maketrans(":\\/", "___", ".")
def get_cache_dir(model_name: str, model_type: ModelType) -> Path: def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
return Path(settings.cache_folder, model_type.value, model_name) return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
settings = Settings() settings = Settings()

View File

@@ -1,4 +1,6 @@
import asyncio
import os import os
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO from io import BytesIO
from typing import Any from typing import Any
@@ -8,6 +10,8 @@ import uvicorn
from fastapi import Body, Depends, FastAPI from fastapi import Body, Depends, FastAPI
from PIL import Image from PIL import Image
from app.models.base import InferenceModel
from .config import settings from .config import settings
from .models.cache import ModelCache from .models.cache import ModelCache
from .schemas import ( from .schemas import (
@@ -25,19 +29,21 @@ app = FastAPI()
def init_state() -> None: def init_state() -> None:
app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0) app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
# asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code
app.state.thread_pool = ThreadPoolExecutor(settings.request_threads)
async def load_models() -> None: async def load_models() -> None:
models = [ models: list[tuple[str, ModelType, dict[str, Any]]] = [
(settings.classification_model, ModelType.IMAGE_CLASSIFICATION), (settings.classification_model, ModelType.IMAGE_CLASSIFICATION, {}),
(settings.clip_image_model, ModelType.CLIP), (settings.clip_image_model, ModelType.CLIP, {"mode": "vision"}),
(settings.clip_text_model, ModelType.CLIP), (settings.clip_text_model, ModelType.CLIP, {"mode": "text"}),
(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION), (settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION, {}),
] ]
# Get all models # Get all models
for model_name, model_type in models: for model_name, model_type, model_kwargs in models:
await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup) await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup, **model_kwargs)
@app.on_event("startup") @app.on_event("startup")
@@ -46,11 +52,16 @@ async def startup_event() -> None:
await load_models() await load_models()
@app.on_event("shutdown")
async def shutdown_event() -> None:
app.state.thread_pool.shutdown()
def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image: def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
return Image.open(BytesIO(byte_image)) return Image.open(BytesIO(byte_image))
def dep_cv_image(byte_image: bytes = Body(...)) -> cv2.Mat: def dep_cv_image(byte_image: bytes = Body(...)) -> np.ndarray[int, np.dtype[Any]]:
byte_image_np = np.frombuffer(byte_image, np.uint8) byte_image_np = np.frombuffer(byte_image, np.uint8)
return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR) return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
@@ -74,7 +85,7 @@ async def image_classification(
image: Image.Image = Depends(dep_pil_image), image: Image.Image = Depends(dep_pil_image),
) -> list[str]: ) -> list[str]:
model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION) model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
labels = model.predict(image) labels = await predict(model, image)
return labels return labels
@@ -86,8 +97,8 @@ async def image_classification(
async def clip_encode_image( async def clip_encode_image(
image: Image.Image = Depends(dep_pil_image), image: Image.Image = Depends(dep_pil_image),
) -> list[float]: ) -> list[float]:
model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP) model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP, mode="vision")
embedding = model.predict(image) embedding = await predict(model, image)
return embedding return embedding
@@ -97,8 +108,8 @@ async def clip_encode_image(
status_code=200, status_code=200,
) )
async def clip_encode_text(payload: TextModelRequest) -> list[float]: async def clip_encode_text(payload: TextModelRequest) -> list[float]:
model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP) model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP, mode="text")
embedding = model.predict(payload.text) embedding = await predict(model, payload.text)
return embedding return embedding
@@ -111,10 +122,14 @@ async def facial_recognition(
image: cv2.Mat = Depends(dep_cv_image), image: cv2.Mat = Depends(dep_cv_image),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION) model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
faces = model.predict(image) faces = await predict(model, image)
return faces return faces
async def predict(model: InferenceModel, inputs: Any) -> Any:
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
if __name__ == "__main__": if __name__ == "__main__":
is_dev = os.getenv("NODE_ENV") == "development" is_dev = os.getenv("NODE_ENV") == "development"
uvicorn.run( uvicorn.run(

View File

@@ -1,3 +1,3 @@
from .clip import CLIPSTEncoder from .clip import CLIPEncoder
from .facial_recognition import FaceRecognizer from .facial_recognition import FaceRecognizer
from .image_classification import ImageClassifier from .image_classification import ImageClassifier

View File

@@ -1,14 +1,17 @@
from __future__ import annotations from __future__ import annotations
import os
import pickle
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any from typing import Any
from zipfile import BadZipFile from zipfile import BadZipFile
import onnxruntime as ort
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf # type: ignore from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf # type: ignore
from ..config import get_cache_dir from ..config import get_cache_dir, settings
from ..schemas import ModelType from ..schemas import ModelType
@@ -16,12 +19,31 @@ class InferenceModel(ABC):
_model_type: ModelType _model_type: ModelType
def __init__( def __init__(
self, model_name: str, cache_dir: Path | str | None = None, eager: bool = True, **model_kwargs: Any self,
model_name: str,
cache_dir: Path | str | None = None,
eager: bool = True,
inter_op_num_threads: int = settings.model_inter_op_threads,
intra_op_num_threads: int = settings.model_intra_op_threads,
**model_kwargs: Any,
) -> None: ) -> None:
self.model_name = model_name self.model_name = model_name
self._loaded = False self._loaded = False
self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type) self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
loader = self.load if eager else self.download loader = self.load if eager else self.download
self.providers = model_kwargs.pop("providers", ["CPUExecutionProvider"])
# don't pre-allocate more memory than needed
self.provider_options = model_kwargs.pop(
"provider_options", [{"arena_extend_strategy": "kSameAsRequested"}] * len(self.providers)
)
self.sess_options = PicklableSessionOptions()
# avoid thread contention between models
if inter_op_num_threads > 1:
self.sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
self.sess_options.inter_op_num_threads = inter_op_num_threads
self.sess_options.intra_op_num_threads = intra_op_num_threads
try: try:
loader(**model_kwargs) loader(**model_kwargs)
except (OSError, InvalidProtobuf, BadZipFile): except (OSError, InvalidProtobuf, BadZipFile):
@@ -30,6 +52,7 @@ class InferenceModel(ABC):
def download(self, **model_kwargs: Any) -> None: def download(self, **model_kwargs: Any) -> None:
if not self.cached: if not self.cached:
print(f"Downloading {self.model_type.value.replace('_', ' ')} model. This may take a while...")
self._download(**model_kwargs) self._download(**model_kwargs)
def load(self, **model_kwargs: Any) -> None: def load(self, **model_kwargs: Any) -> None:
@@ -39,6 +62,7 @@ class InferenceModel(ABC):
def predict(self, inputs: Any) -> Any: def predict(self, inputs: Any) -> Any:
if not self._loaded: if not self._loaded:
print(f"Loading {self.model_type.value.replace('_', ' ')} model...")
self.load() self.load()
return self._predict(inputs) return self._predict(inputs)
@@ -89,3 +113,14 @@ class InferenceModel(ABC):
else: else:
self.cache_dir.unlink() self.cache_dir.unlink()
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
# HF deep copies configs, so we need to make session options picklable
class PicklableSessionOptions(ort.SessionOptions):
def __getstate__(self) -> bytes:
return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))])
def __setstate__(self, state: Any) -> None:
self.__init__() # type: ignore
for attr, val in pickle.loads(state):
setattr(self, attr, val)

View File

@@ -46,7 +46,7 @@ class ModelCache:
model: The requested model. model: The requested model.
""" """
key = self.cache.build_key(model_name, model_type.value) key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
async with OptimisticLock(self.cache, key) as lock: async with OptimisticLock(self.cache, key) as lock:
model = await self.cache.get(key) model = await self.cache.get(key)
if model is None: if model is None:

View File

@@ -1,31 +1,141 @@
from typing import Any import os
import zipfile
from typing import Any, Literal
import onnxruntime as ort
import torch
from clip_server.model.clip import BICUBIC, _convert_image_to_rgb
from clip_server.model.clip_onnx import _MODELS, _S3_BUCKET_V2, CLIPOnnxModel, download_model
from clip_server.model.pretrained_models import _VISUAL_MODEL_IMAGE_SIZE
from clip_server.model.tokenization import Tokenizer
from PIL.Image import Image from PIL.Image import Image
from sentence_transformers import SentenceTransformer from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
from sentence_transformers.util import snapshot_download
from ..schemas import ModelType from ..schemas import ModelType
from .base import InferenceModel from .base import InferenceModel
_ST_TO_JINA_MODEL_NAME = {
"clip-ViT-B-16": "ViT-B-16::openai",
"clip-ViT-B-32": "ViT-B-32::openai",
"clip-ViT-B-32-multilingual-v1": "M-CLIP/XLM-Roberta-Large-Vit-B-32",
"clip-ViT-L-14": "ViT-L-14::openai",
}
class CLIPSTEncoder(InferenceModel):
class CLIPEncoder(InferenceModel):
_model_type = ModelType.CLIP _model_type = ModelType.CLIP
def __init__(
self,
model_name: str,
cache_dir: str | None = None,
mode: Literal["text", "vision"] | None = None,
**model_kwargs: Any,
) -> None:
if mode is not None and mode not in ("text", "vision"):
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
if "vit-b" not in model_name.lower():
raise ValueError(f"Only ViT-B models are currently supported; got '{model_name}'")
self.mode = mode
jina_model_name = self._get_jina_model_name(model_name)
super().__init__(jina_model_name, cache_dir, **model_kwargs)
def _download(self, **model_kwargs: Any) -> None: def _download(self, **model_kwargs: Any) -> None:
repo_id = self.model_name if "/" in self.model_name else f"sentence-transformers/{self.model_name}" models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
snapshot_download( text_onnx_path = self.cache_dir / "textual.onnx"
cache_dir=self.cache_dir, vision_onnx_path = self.cache_dir / "visual.onnx"
repo_id=repo_id,
library_name="sentence-transformers", if not text_onnx_path.is_file():
ignore_files=["flax_model.msgpack", "rust_model.ot", "tf_model.h5"], self._download_model(*models[0])
)
if not vision_onnx_path.is_file():
self._download_model(*models[1])
def _load(self, **model_kwargs: Any) -> None: def _load(self, **model_kwargs: Any) -> None:
self.model = SentenceTransformer( if self.mode == "text" or self.mode is None:
self.model_name, self.text_model = ort.InferenceSession(
cache_folder=self.cache_dir.as_posix(), self.cache_dir / "textual.onnx",
**model_kwargs, sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
) )
self.text_outputs = [output.name for output in self.text_model.get_outputs()]
self.tokenizer = Tokenizer(self.model_name)
if self.mode == "vision" or self.mode is None:
self.vision_model = ort.InferenceSession(
self.cache_dir / "visual.onnx",
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
self.vision_outputs = [output.name for output in self.vision_model.get_outputs()]
image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
self.transform = _transform_pil_image(image_size)
def _predict(self, image_or_text: Image | str) -> list[float]: def _predict(self, image_or_text: Image | str) -> list[float]:
return self.model.encode(image_or_text).tolist() match image_or_text:
case Image():
if self.mode == "text":
raise TypeError("Cannot encode image as text-only model")
pixel_values = self.transform(image_or_text)
assert isinstance(pixel_values, torch.Tensor)
pixel_values = torch.unsqueeze(pixel_values, 0).numpy()
outputs = self.vision_model.run(self.vision_outputs, {"pixel_values": pixel_values})
case str():
if self.mode == "vision":
raise TypeError("Cannot encode text as vision-only model")
text_inputs: dict[str, torch.Tensor] = self.tokenizer(image_or_text)
inputs = {
"input_ids": text_inputs["input_ids"].int().numpy(),
"attention_mask": text_inputs["attention_mask"].int().numpy(),
}
outputs = self.text_model.run(self.text_outputs, inputs)
case _:
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
return outputs[0][0].tolist()
def _get_jina_model_name(self, model_name: str) -> str:
if model_name in _MODELS:
return model_name
elif model_name in _ST_TO_JINA_MODEL_NAME:
print(
(f"Warning: Sentence-Transformer model names such as '{model_name}' are no longer supported."),
(f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."),
)
return _ST_TO_JINA_MODEL_NAME[model_name]
else:
raise ValueError(f"Unknown model name {model_name}.")
def _download_model(self, model_name: str, model_md5: str) -> bool:
# downloading logic is adapted from clip-server's CLIPOnnxModel class
download_model(
url=_S3_BUCKET_V2 + model_name,
target_folder=self.cache_dir.as_posix(),
md5sum=model_md5,
with_resume=True,
)
file = self.cache_dir / model_name.split("/")[1]
if file.suffix == ".zip":
with zipfile.ZipFile(file, "r") as zip_ref:
zip_ref.extractall(self.cache_dir)
os.remove(file)
return True
# same as `_transform_blob` without `_blob2image`
def _transform_pil_image(n_px: int) -> Compose:
return Compose(
[
Resize(n_px, interpolation=BICUBIC),
CenterCrop(n_px),
_convert_image_to_rgb,
ToTensor(),
Normalize(
(0.48145466, 0.4578275, 0.40821073),
(0.26862954, 0.26130258, 0.27577711),
),
]
)

View File

@@ -4,6 +4,7 @@ from typing import Any
import cv2 import cv2
import numpy as np import numpy as np
import onnxruntime as ort
from insightface.model_zoo import ArcFaceONNX, RetinaFace from insightface.model_zoo import ArcFaceONNX, RetinaFace
from insightface.utils.face_align import norm_crop from insightface.utils.face_align import norm_crop
from insightface.utils.storage import BASE_REPO_URL, download_file from insightface.utils.storage import BASE_REPO_URL, download_file
@@ -42,15 +43,31 @@ class FaceRecognizer(InferenceModel):
rec_file = next(self.cache_dir.glob("w600k_*.onnx")) rec_file = next(self.cache_dir.glob("w600k_*.onnx"))
except StopIteration: except StopIteration:
raise FileNotFoundError("Facial recognition models not found in cache directory") raise FileNotFoundError("Facial recognition models not found in cache directory")
self.det_model = RetinaFace(det_file.as_posix())
self.rec_model = ArcFaceONNX(rec_file.as_posix()) self.det_model = RetinaFace(
session=ort.InferenceSession(
det_file.as_posix(),
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
),
)
self.rec_model = ArcFaceONNX(
rec_file.as_posix(),
session=ort.InferenceSession(
rec_file.as_posix(),
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
),
)
self.det_model.prepare( self.det_model.prepare(
ctx_id=-1, ctx_id=0,
det_thresh=self.min_score, det_thresh=self.min_score,
input_size=(640, 640), input_size=(640, 640),
) )
self.rec_model.prepare(ctx_id=-1) self.rec_model.prepare(ctx_id=0)
def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]: def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
bboxes, kpss = self.det_model.detect(image) bboxes, kpss = self.det_model.detect(image)

View File

@@ -2,8 +2,10 @@ from pathlib import Path
from typing import Any from typing import Any
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from optimum.onnxruntime import ORTModelForImageClassification
from optimum.pipelines import pipeline
from PIL.Image import Image from PIL.Image import Image
from transformers.pipelines import pipeline from transformers import AutoImageProcessor
from ..config import settings from ..config import settings
from ..schemas import ModelType from ..schemas import ModelType
@@ -25,14 +27,33 @@ class ImageClassifier(InferenceModel):
def _download(self, **model_kwargs: Any) -> None: def _download(self, **model_kwargs: Any) -> None:
snapshot_download( snapshot_download(
cache_dir=self.cache_dir, repo_id=self.model_name, allow_patterns=["*.bin", "*.json", "*.txt"] cache_dir=self.cache_dir,
repo_id=self.model_name,
allow_patterns=["*.bin", "*.json", "*.txt"],
local_dir=self.cache_dir,
local_dir_use_symlinks=True,
) )
def _load(self, **model_kwargs: Any) -> None: def _load(self, **model_kwargs: Any) -> None:
processor = AutoImageProcessor.from_pretrained(self.cache_dir)
model_kwargs |= {
"cache_dir": self.cache_dir,
"provider": self.providers[0],
"provider_options": self.provider_options[0],
"session_options": self.sess_options,
}
model_path = self.cache_dir / "model.onnx"
if model_path.exists():
model = ORTModelForImageClassification.from_pretrained(self.cache_dir, **model_kwargs)
self.model = pipeline(self.model_type.value, model, feature_extractor=processor)
else:
self.sess_options.optimized_model_filepath = model_path.as_posix()
self.model = pipeline( self.model = pipeline(
self.model_type.value, self.model_type.value,
self.model_name, self.model_name,
model_kwargs={"cache_dir": self.cache_dir, **model_kwargs}, model_kwargs=model_kwargs,
feature_extractor=processor,
) )
def _predict(self, image: Image) -> list[str]: def _predict(self, image: Image) -> list[str]:

View File

@@ -1,17 +1,20 @@
import pickle
from io import BytesIO from io import BytesIO
from typing import TypeAlias from typing import TypeAlias
from unittest import mock from unittest import mock
import cv2 import cv2
import numpy as np import numpy as np
import onnxruntime as ort
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from PIL import Image from PIL import Image
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from .config import settings from .config import settings
from .models.base import PicklableSessionOptions
from .models.cache import ModelCache from .models.cache import ModelCache
from .models.clip import CLIPSTEncoder from .models.clip import CLIPEncoder
from .models.facial_recognition import FaceRecognizer from .models.facial_recognition import FaceRecognizer
from .models.image_classification import ImageClassifier from .models.image_classification import ImageClassifier
from .schemas import ModelType from .schemas import ModelType
@@ -72,45 +75,47 @@ class TestCLIP:
embedding = np.random.rand(512).astype(np.float32) embedding = np.random.rand(512).astype(np.float32)
def test_eager_init(self, mocker: MockerFixture) -> None: def test_eager_init(self, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPSTEncoder, "download") mocker.patch.object(CLIPEncoder, "download")
mock_load = mocker.patch.object(CLIPSTEncoder, "load") mock_load = mocker.patch.object(CLIPEncoder, "load")
clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=True, test_arg="test_arg") clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=True, test_arg="test_arg")
assert clip_model.model_name == "test_model_name" assert clip_model.model_name == "ViT-B-32::openai"
mock_load.assert_called_once_with(test_arg="test_arg") mock_load.assert_called_once_with(test_arg="test_arg")
def test_lazy_init(self, mocker: MockerFixture) -> None: def test_lazy_init(self, mocker: MockerFixture) -> None:
mock_download = mocker.patch.object(CLIPSTEncoder, "download") mock_download = mocker.patch.object(CLIPEncoder, "download")
mock_load = mocker.patch.object(CLIPSTEncoder, "load") mock_load = mocker.patch.object(CLIPEncoder, "load")
clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=False, test_arg="test_arg") clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=False, test_arg="test_arg")
assert clip_model.model_name == "test_model_name" assert clip_model.model_name == "ViT-B-32::openai"
mock_download.assert_called_once_with(test_arg="test_arg") mock_download.assert_called_once_with(test_arg="test_arg")
mock_load.assert_not_called() mock_load.assert_not_called()
def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None: def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPSTEncoder, "load") mocker.patch.object(CLIPEncoder, "download")
clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache") mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
clip_encoder.model = mock.Mock() mocked.return_value.run.return_value = [[self.embedding]]
clip_encoder.model.encode.return_value = self.embedding clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
assert clip_encoder.mode == "vision"
embedding = clip_encoder.predict(pil_image) embedding = clip_encoder.predict(pil_image)
assert isinstance(embedding, list) assert isinstance(embedding, list)
assert len(embedding) == 512 assert len(embedding) == 512
assert all([isinstance(num, float) for num in embedding]) assert all([isinstance(num, float) for num in embedding])
clip_encoder.model.encode.assert_called_once() clip_encoder.vision_model.run.assert_called_once()
def test_basic_text(self, mocker: MockerFixture) -> None: def test_basic_text(self, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPSTEncoder, "load") mocker.patch.object(CLIPEncoder, "download")
clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache") mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
clip_encoder.model = mock.Mock() mocked.return_value.run.return_value = [[self.embedding]]
clip_encoder.model.encode.return_value = self.embedding clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
assert clip_encoder.mode == "text"
embedding = clip_encoder.predict("test search query") embedding = clip_encoder.predict("test search query")
assert isinstance(embedding, list) assert isinstance(embedding, list)
assert len(embedding) == 512 assert len(embedding) == 512
assert all([isinstance(num, float) for num in embedding]) assert all([isinstance(num, float) for num in embedding])
clip_encoder.model.encode.assert_called_once() clip_encoder.text_model.run.assert_called_once()
class TestFaceRecognition: class TestFaceRecognition:
@@ -254,3 +259,13 @@ class TestEndpoints:
headers=headers, headers=headers,
) )
assert response.status_code == 200 assert response.status_code == 200
def test_sess_options() -> None:
sess_options = PicklableSessionOptions()
sess_options.intra_op_num_threads = 1
sess_options.inter_op_num_threads = 1
pickled = pickle.dumps(sess_options)
unpickled = pickle.loads(pickled)
assert unpickled.intra_op_num_threads == 1
assert unpickled.inter_op_num_threads == 1

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.73.0" version = "1.75.1"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
@@ -13,7 +13,6 @@ torch = [
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"} {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
] ]
transformers = "^4.29.2" transformers = "^4.29.2"
sentence-transformers = "^2.2.2"
onnxruntime = "^1.15.0" onnxruntime = "^1.15.0"
insightface = "^0.7.3" insightface = "^0.7.3"
opencv-python-headless = "^4.7.0.72" opencv-python-headless = "^4.7.0.72"
@@ -22,6 +21,15 @@ fastapi = "^0.95.2"
uvicorn = {extras = ["standard"], version = "^0.22.0"} uvicorn = {extras = ["standard"], version = "^0.22.0"}
pydantic = "^1.10.8" pydantic = "^1.10.8"
aiocache = "^0.12.1" aiocache = "^0.12.1"
optimum = "^1.9.1"
torchvision = [
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=0.15.2", source = "pypi"},
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=0.15.2", source = "pytorch-cpu"}
]
rich = "^13.4.2"
ftfy = "^6.1.1"
setuptools = "^68.0.0"
open-clip-torch = "^2.20.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
mypy = "^1.3.0" mypy = "^1.3.0"
@@ -62,13 +70,20 @@ warn_untyped_fields = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"huggingface_hub", "huggingface_hub",
"transformers.pipelines", "transformers",
"cv2", "cv2",
"insightface.model_zoo", "insightface.model_zoo",
"insightface.utils.face_align", "insightface.utils.face_align",
"insightface.utils.storage", "insightface.utils.storage",
"sentence_transformers", "onnxruntime",
"sentence_transformers.util", "optimum",
"optimum.pipelines",
"optimum.onnxruntime",
"clip_server.model.clip",
"clip_server.model.clip_onnx",
"clip_server.model.pretrained_models",
"clip_server.model.tokenization",
"torchvision.transforms",
"aiocache.backends.memory", "aiocache.backends.memory",
"aiocache.lock", "aiocache.lock",
"aiocache.plugins" "aiocache.plugins"

View File

@@ -0,0 +1,2 @@
# requirements to be installed with `--no-deps` flag
clip-server==0.8.*

View File

@@ -1,4 +1,4 @@
{ {
"flutterSdkVersion": "3.10.5", "flutterSdkVersion": "3.13.0",
"flavors": {} "flavors": {}
} }

View File

@@ -52,7 +52,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich" applicationId "app.alextran.immich"
minSdkVersion 23 minSdkVersion 26
targetSdkVersion 33 targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

View File

@@ -56,7 +56,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 96, "android.injected.version.code" => 98,
"android.injected.version.name" => "1.73.0", "android.injected.version.name" => "1.75.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') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="68.788432"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.877631">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.76592"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="23.895222">
</testcase> </testcase>

View File

@@ -300,5 +300,6 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"translated_text_options": "Options"
} }

View File

@@ -33,7 +33,7 @@ PODS:
- FlutterMacOS - FlutterMacOS
- path_provider_ios (0.0.1): - path_provider_ios (0.0.1):
- Flutter - Flutter
- permission_handler_apple (9.0.4): - permission_handler_apple (9.1.1):
- Flutter - Flutter
- photo_manager (2.0.0): - photo_manager (2.0.0):
- Flutter - Flutter
@@ -53,7 +53,7 @@ PODS:
- Flutter - Flutter
- video_player_avfoundation (0.0.1): - video_player_avfoundation (0.0.1):
- Flutter - Flutter
- wakelock (0.0.1): - wakelock_plus (0.0.1):
- Flutter - Flutter
DEPENDENCIES: DEPENDENCIES:
@@ -78,7 +78,7 @@ DEPENDENCIES:
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -130,8 +130,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation: video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios" :path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock: wakelock_plus:
:path: ".symlinks/plugins/wakelock/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
@@ -141,25 +141,25 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0 fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670 integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126 video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382

View File

@@ -171,7 +171,7 @@
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastUpgradeCheck = 1300; LastUpgradeCheck = 1430;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {
@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 110; CURRENT_PROJECT_VERSION = 113;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 110; CURRENT_PROJECT_VERSION = 113;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 110; CURRENT_PROJECT_VERSION = 113;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1300" LastUpgradeVersion = "1430"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -59,11 +59,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.70.0</string> <string>1.73.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>110</string> <string>113</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true /> <true />
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -1,4 +1,4 @@
#!/bin/sh #!/usr/bin/env sh
# The default execution directory of this script is the ci_scripts directory. # The default execution directory of this script is the ci_scripts directory.
cd $CI_WORKSPACE/mobile cd $CI_WORKSPACE/mobile

View File

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

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000187">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.108738"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.403882">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="28.952846"> <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.068392">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.821481"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.988079">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="99.212621"> <testcase classname="fastlane.lanes" name="4: build_app" time="96.47923">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.366701"> <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="57.517755">
</testcase> </testcase>

View File

@@ -139,6 +139,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
debugPrint("[APP STATE] detached"); debugPrint("[APP STATE] detached");
ref.read(appStateProvider.notifier).handleAppDetached(); ref.read(appStateProvider.notifier).handleAppDetached();
break; break;
case AppLifecycleState.hidden:
debugPrint("[APP STATE] hidden");
ref.read(appStateProvider.notifier).handleAppHidden();
break;
} }
} }

View File

@@ -56,6 +56,16 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
return _albumService.removeAssetFromAlbum(album, assets); return _albumService.removeAssetFromAlbum(album, assets);
} }
Future<bool> removeUserFromAlbum(Album album, User user) async {
final result = await _albumService.removeUserFromAlbum(album, user);
if (result && album.sharedUsers.isEmpty) {
state = state.where((element) => element.id != album.id).toList();
}
return result;
}
@override @override
void dispose() { void dispose() {
_streamSub.cancel(); _streamSub.cancel();

View File

@@ -348,6 +348,26 @@ class AlbumService {
} }
} }
Future<bool> removeUserFromAlbum(
Album album,
User user,
) async {
try {
await _apiService.albumApi.removeUserFromAlbum(
album.remoteId!,
user.id,
);
album.sharedUsers.remove(user);
await _db.writeTxn(() => album.sharedUsers.update(unlink: [user]));
return true;
} catch (e) {
debugPrint("Error removeUserFromAlbum ${e.toString()}");
return false;
}
}
Future<bool> changeTitleAlbum( Future<bool> changeTitleAlbum(
Album album, Album album,
String newAlbumTitle, String newAlbumTitle,

View File

@@ -49,7 +49,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
type: ThumbnailFormat.JPEG, type: ThumbnailFormat.JPEG,
), ),
httpHeaders: { httpHeaders: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}" "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
}, },
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
errorWidget: (context, url, error) => errorWidget: (context, url, error) =>
@@ -105,9 +105,9 @@ class AlbumThumbnailListTile extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
), ),
).tr() ).tr(),
], ],
) ),
], ],
), ),
), ),

View File

@@ -69,6 +69,11 @@ class AlbumTitleTextField extends ConsumerWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
hintText: 'share_add_title'.tr(), hintText: 'share_add_title'.tr(),
hintStyle: TextStyle(
fontSize: 28,
color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
fontWeight: FontWeight.bold,
),
focusColor: Colors.grey[300], focusColor: Colors.grey[300],
fillColor: isDarkTheme fillColor: isDarkTheme
? const Color.fromARGB(255, 32, 33, 35) ? const Color.fromARGB(255, 32, 33, 35)

View File

@@ -39,7 +39,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void onDeleteAlbumPressed() async { deleteAlbum() async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
final bool success; final bool success;
@@ -65,6 +65,52 @@ class AlbumViewerAppbar extends HookConsumerWidget
ImmichLoadingOverlayController.appLoader.hide(); ImmichLoadingOverlayController.appLoader.hide();
} }
Future<void> showConfirmationDialog() async {
return showDialog<void>(
context: context,
barrierDismissible: false, // user must tap button!
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Delete album'),
content: const Text(
'Are you sure you want to delete this album from your account?',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, 'Cancel'),
child: Text(
'Cancel',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () {
Navigator.pop(context, 'Confirm');
deleteAlbum();
},
child: Text(
'Confirm',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).brightness == Brightness.light
? Colors.red
: Colors.red[300],
),
),
),
],
);
},
);
}
void onDeleteAlbumPressed() async {
showConfirmationDialog();
}
void onLeaveAlbumPressed() async { void onLeaveAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
@@ -152,31 +198,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
} }
void buildBottomSheet() { void buildBottomSheet() {
showModalBottomSheet( final ownerActions = [
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildBottomSheetActionButton(),
if (selected.isEmpty && onAddPhotos != null)
ListTile(
leading: const Icon(Icons.add_photo_alternate_outlined),
onTap: () {
Navigator.pop(context);
onAddPhotos!(album);
},
title: const Text(
"share_add_photos",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
if (selected.isEmpty &&
onAddPhotos != null &&
userId == album.ownerId)
ListTile( ListTile(
leading: const Icon(Icons.person_add_alt_rounded), leading: const Icon(Icons.person_add_alt_rounded),
onTap: () { onTap: () {
@@ -188,8 +210,50 @@ class AlbumViewerAppbar extends HookConsumerWidget
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
).tr(), ).tr(),
), ),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () =>
AutoRouter.of(context).navigate(AlbumOptionsRoute(album: album)),
title: const Text(
"translated_text_options",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
];
final commonActions = [
ListTile(
leading: const Icon(Icons.add_photo_alternate_outlined),
onTap: () {
Navigator.pop(context);
onAddPhotos!(album);
},
title: const Text(
"share_add_photos",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
];
showModalBottomSheet(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildBottomSheetActionButton(),
if (selected.isEmpty && onAddPhotos != null) ...commonActions,
if (selected.isEmpty &&
onAddPhotos != null &&
userId == album.ownerId)
...ownerActions,
], ],
), ),
),
); );
}, },
); );
@@ -217,6 +281,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
toastType: ToastType.error, toastType: ToastType.error,
); );
} }
titleFocusNode.unfocus();
}, },
icon: const Icon(Icons.check_rounded), icon: const Icon(Icons.check_rounded),
splashRadius: 25, splashRadius: 25,

View File

@@ -84,6 +84,11 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
: Colors.grey[200], : Colors.grey[200],
filled: titleFocusNode.hasFocus, filled: titleFocusNode.hasFocus,
hintText: 'share_add_title'.tr(), hintText: 'share_add_title'.tr(),
hintStyle: TextStyle(
fontSize: 28,
color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
fontWeight: FontWeight.bold,
),
), ),
); );
} }

View File

@@ -0,0 +1,205 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumOptionsPage extends HookConsumerWidget {
final Album album;
const AlbumOptionsPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsers = useState(album.sharedUsers.toList());
final owner = album.owner.value;
final userId = ref.watch(authenticationProvider).userId;
final isOwner = owner?.id == userId;
void showErrorMessage() {
Navigator.pop(context);
ImmichToast.show(
context: context,
msg: "Error leaving/removing from album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
void leaveAlbum() async {
ImmichLoadingOverlayController.appLoader.show();
try {
final isSuccess =
await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album);
if (isSuccess) {
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
showErrorMessage();
}
} catch (_) {
showErrorMessage();
}
ImmichLoadingOverlayController.appLoader.hide();
}
void removeUserFromAlbum(User user) async {
ImmichLoadingOverlayController.appLoader.show();
try {
await ref
.read(sharedAlbumProvider.notifier)
.removeUserFromAlbum(album, user);
album.sharedUsers.remove(user);
sharedUsers.value = album.sharedUsers.toList();
} catch (error) {
showErrorMessage();
}
Navigator.pop(context);
ImmichLoadingOverlayController.appLoader.hide();
}
void handleUserClick(User user) {
var actions = [];
if (user.id == userId) {
actions = [
ListTile(
leading: const Icon(Icons.exit_to_app_rounded),
title: const Text("Leave album"),
onTap: leaveAlbum,
),
];
}
if (isOwner) {
actions = [
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text("Remove user from album"),
onTap: () => removeUserFromAlbum(user),
),
];
}
showModalBottomSheet(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [...actions],
),
),
);
},
);
}
buildOwnerInfo() {
return ListTile(
leading: owner != null
? UserCircleAvatar(
user: owner,
useRandomBackgroundColor: true,
)
: const SizedBox(),
title: Text(
album.owner.value?.firstName ?? "",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
album.owner.value?.email ?? "",
style: TextStyle(color: Colors.grey[500]),
),
trailing: const Text(
"Owner",
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
);
}
buildSharedUsersList() {
return ListView.builder(
shrinkWrap: true,
itemCount: sharedUsers.value.length,
itemBuilder: (context, index) {
final user = sharedUsers.value[index];
return ListTile(
leading: UserCircleAvatar(
user: user,
useRandomBackgroundColor: true,
radius: 22,
),
title: Text(
user.firstName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
user.email,
style: TextStyle(color: Colors.grey[500]),
),
trailing: userId == user.id || isOwner
? const Icon(Icons.more_horiz_rounded)
: const SizedBox(),
onTap: userId == user.id || isOwner
? () => handleUserClick(user)
: null,
);
},
);
}
buildSectionTitle(String text) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () {
AutoRouter.of(context).pop(null);
},
),
centerTitle: true,
title: Text("translated_text_options".tr()),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildSectionTitle("PEOPLE"),
buildOwnerInfo(),
buildSharedUsersList(),
],
),
);
}
}

View File

@@ -17,6 +17,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.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'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerPage extends HookConsumerWidget { class AlbumViewerPage extends HookConsumerWidget {
@@ -116,7 +117,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildControlButton(Album album) { Widget buildControlButton(Album album) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8), padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox( child: SizedBox(
height: 40, height: 40,
child: ListView( child: ListView(
@@ -141,7 +142,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildTitle(Album album) { Widget buildTitle(Album album) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16), padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
child: userId == album.ownerId && album.isRemote child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle( ? AlbumViewerEditableTitle(
album: album, album: album,
@@ -172,7 +173,6 @@ class AlbumViewerPage extends HookConsumerWidget {
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 16.0, left: 16.0,
top: 8.0,
bottom: album.shared ? 0.0 : 8.0, bottom: album.shared ? 0.0 : 8.0,
), ),
child: Text( child: Text(
@@ -180,7 +180,34 @@ class AlbumViewerPage extends HookConsumerWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.grey, ),
),
);
}
Widget buildSharedUserIconsRow(Album album) {
return GestureDetector(
onTap: () async {
await AutoRouter.of(context).push(AlbumOptionsRoute(album: album));
ref.invalidate(albumDetailProvider(album.id));
},
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: UserCircleAvatar(
user: album.sharedUsers.toList()[index],
radius: 18,
size: 36,
useRandomBackgroundColor: true,
),
);
}),
itemCount: album.sharedUsers.length,
), ),
), ),
); );
@@ -193,33 +220,7 @@ class AlbumViewerPage extends HookConsumerWidget {
children: [ children: [
buildTitle(album), buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared) if (album.shared) buildSharedUserIconsRow(album),
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: CircleAvatar(
backgroundColor: Colors.grey[300],
radius: 18,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(50.0),
child: Image.asset(
'assets/immich-logo-no-outline.png',
),
),
),
),
);
}),
itemCount: album.sharedUsers.length,
),
),
], ],
); );
} }

View File

@@ -73,9 +73,12 @@ class AssetSelectionPage extends HookConsumerWidget {
AutoRouter.of(context) AutoRouter.of(context)
.popForced<AssetSelectionPageResult>(payload); .popForced<AssetSelectionPageResult>(payload);
}, },
child: const Text( child: Text(
"share_add", "share_add",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
).tr(), ).tr(),
), ),
], ],

View File

@@ -30,7 +30,8 @@ class CreateAlbumPage extends HookConsumerWidget {
final albumTitleTextFieldFocusNode = useFocusNode(); final albumTitleTextFieldFocusNode = useFocusNode();
final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true); final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>(initialAssets != null ? Set.from(initialAssets!) : const {}); final selectedAssets = useState<Set<Asset>>(
initialAssets != null ? Set.from(initialAssets!) : const {},);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark; final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
showSelectUserPage() async { showSelectUserPage() async {
@@ -248,8 +249,9 @@ class CreateAlbumPage extends HookConsumerWidget {
: null, : null,
child: Text( child: Text(
'create_shared_album_page_create'.tr(), 'create_shared_album_page_create'.tr(),
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
), ),
), ),
), ),

View File

@@ -60,7 +60,7 @@ class LibraryPage extends HookConsumerWidget {
Widget buildSortButton() { Widget buildSortButton() {
final options = [ final options = [
"library_page_sort_created".tr(), "library_page_sort_created".tr(),
"library_page_sort_title".tr() "library_page_sort_title".tr(),
]; ];
return PopupMenuButton( return PopupMenuButton(
@@ -87,7 +87,7 @@ class LibraryPage extends HookConsumerWidget {
color: selected ? Theme.of(context).primaryColor : null, color: selected ? Theme.of(context).primaryColor : null,
fontSize: 12.0, fontSize: 12.0,
), ),
) ),
], ],
), ),
); );

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/providers/suggested_shared_users.pro
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
class SelectAdditionalUserForSharingPage extends HookConsumerWidget { class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
final Album album; final Album album;
@@ -35,10 +36,8 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
), ),
); );
} else { } else {
return CircleAvatar( return UserCircleAvatar(
backgroundImage: user: user,
const AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
); );
} }
} }
@@ -103,7 +102,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
} else { } else {
sharedUsersList.value = { sharedUsersList.value = {
...sharedUsersList.value, ...sharedUsersList.value,
users[index] users[index],
}; };
} }
}, },
@@ -136,7 +135,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
"share_add", "share_add",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(), ).tr(),
) ),
], ],
), ),
body: suggestedShareUsers.when( body: suggestedShareUsers.when(

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
class SelectUserForSharingPage extends HookConsumerWidget { class SelectUserForSharingPage extends HookConsumerWidget {
const SelectUserForSharingPage({Key? key, required this.assets}) const SelectUserForSharingPage({Key? key, required this.assets})
@@ -56,10 +57,8 @@ class SelectUserForSharingPage extends HookConsumerWidget {
), ),
); );
} else { } else {
return CircleAvatar( return UserCircleAvatar(
backgroundImage: user: user,
const AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
); );
} }
} }
@@ -124,7 +123,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
} else { } else {
sharedUsersList.value = { sharedUsersList.value = {
...sharedUsersList.value, ...sharedUsersList.value,
users[index] users[index],
}; };
} }
}, },
@@ -164,7 +163,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
// color: Theme.of(context).primaryColor, // color: Theme.of(context).primaryColor,
), ),
).tr(), ).tr(),
) ),
], ],
), ),
body: suggestedShareUsers.when( body: suggestedShareUsers.when(

View File

@@ -160,7 +160,7 @@ class SharingPage extends HookConsumerWidget {
maxLines: 1, maxLines: 1,
).tr(), ).tr(),
), ),
) ),
], ],
), ),
); );

View File

@@ -91,7 +91,7 @@ class ArchivePage extends HookConsumerWidget {
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
} }
}, },
) ),
], ],
), ),
), ),
@@ -124,7 +124,7 @@ class ArchivePage extends HookConsumerWidget {
), ),
if (selectionEnabledHook.value) buildBottomBar(), if (selectionEnabledHook.value) buildBottomBar(),
if (processing.value) if (processing.value)
const Center(child: ImmichLoadingIndicator()) const Center(child: ImmichLoadingIndicator()),
], ],
), ),
), ),

View File

@@ -16,16 +16,32 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key); const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
bool get showMap => bool get hasCoordinates =>
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null; asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null;
Future<Uri> _createCoordinatesUri(double latitude, double longitude) async { String get formattedDateTime {
const zoomLevel = 5; final fileCreatedAt = asset.fileCreatedAt.toLocal();
final date = DateFormat.yMMMEd().format(fileCreatedAt);
final time = DateFormat.jm().format(fileCreatedAt);
return '$date$time';
}
Future<Uri?> _createCoordinatesUri() async {
if (!hasCoordinates) {
return null;
}
double latitude = asset.exifInfo!.latitude!;
double longitude = asset.exifInfo!.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) { if (Platform.isAndroid) {
Uri uri = Uri( Uri uri = Uri(
scheme: 'geo', scheme: 'geo',
host: '$latitude,$longitude', host: '$latitude,$longitude',
queryParameters: {'z': '$zoomLevel', 'q': '$latitude,$longitude'}, queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime},
); );
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
return uri; return uri;
@@ -33,16 +49,20 @@ class ExifBottomSheet extends HookConsumerWidget {
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
var params = { var params = {
'll': '$latitude,$longitude', 'll': '$latitude,$longitude',
'q': '$latitude, $longitude', 'q': formattedDateTime,
'z': '$zoomLevel',
}; };
Uri uri = Uri.https('maps.apple.com', '/', params); Uri uri = Uri.https('maps.apple.com', '/', params);
if (!await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
return uri; return uri;
} }
} }
return Uri.https(
'www.google.com', return Uri(
'/maps/place/$latitude,$longitude/@$latitude,$longitude,${zoomLevel}z', scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
); );
} }
@@ -72,16 +92,14 @@ class ExifBottomSheet extends HookConsumerWidget {
), ),
zoom: 16.0, zoom: 16.0,
onTap: (tapPosition, latLong) async { onTap: (tapPosition, latLong) async {
if (exifInfo != null && Uri? uri = await _createCoordinatesUri();
exifInfo.latitude != null &&
exifInfo.longitude != null) { if (uri == null) {
launchUrl( return;
await _createCoordinatesUri(
exifInfo.latitude!,
exifInfo.longitude!,
),
);
} }
debugPrint('Opening Map Uri: $uri');
launchUrl(uri);
}, },
), ),
nonRotatedChildren: [ nonRotatedChildren: [
@@ -151,7 +169,7 @@ class ExifBottomSheet extends HookConsumerWidget {
buildLocation() { buildLocation() {
// Guard no lat/lng // Guard no lat/lng
if (!showMap) { if (!hasCoordinates) {
return Container(); return Container();
} }
@@ -199,7 +217,7 @@ class ExifBottomSheet extends HookConsumerWidget {
Text( Text(
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
) ),
], ],
), ),
], ],
@@ -207,12 +225,8 @@ class ExifBottomSheet extends HookConsumerWidget {
} }
buildDate() { buildDate() {
final fileCreatedAt = asset.fileCreatedAt.toLocal();
final date = DateFormat.yMMMEd().format(fileCreatedAt);
final time = DateFormat.jm().format(fileCreatedAt);
return Text( return Text(
'$date$time', formattedDateTime,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
@@ -306,7 +320,7 @@ class ExifBottomSheet extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Flexible( Flexible(
flex: showMap ? 5 : 0, flex: hasCoordinates ? 5 : 0,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: buildLocation(), child: buildLocation(),
@@ -336,7 +350,7 @@ class ExifBottomSheet extends HookConsumerWidget {
if (asset.isRemote) DescriptionInput(asset: asset), if (asset.isRemote) DescriptionInput(asset: asset),
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
buildLocation(), buildLocation(),
SizedBox(height: showMap ? 16.0 : 0.0), SizedBox(height: hasCoordinates ? 16.0 : 0.0),
buildDetail(), buildDetail(),
const SizedBox(height: 50), const SizedBox(height: 50),
], ],

View File

@@ -128,7 +128,7 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal) buildDownloadButton(), if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
if (asset.isRemote) buildAddToAlbumButtom(), if (asset.isRemote) buildAddToAlbumButtom(),
buildMoreInfoButton() buildMoreInfoButton(),
], ],
); );
} }

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:wakelock/wakelock.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget { class VideoViewerPage extends HookConsumerWidget {
@@ -136,16 +136,16 @@ class _VideoPlayerState extends State<VideoPlayer> {
videoPlayerController.addListener(() { videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) { if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) { if (videoPlayerController.value.isPlaying) {
Wakelock.enable(); WakelockPlus.enable();
widget.onPlaying?.call(); widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) { } else if (!videoPlayerController.value.isPlaying) {
Wakelock.disable(); WakelockPlus.disable();
widget.onPaused?.call(); widget.onPaused?.call();
} }
if (videoPlayerController.value.position == if (videoPlayerController.value.position ==
videoPlayerController.value.duration) { videoPlayerController.value.duration) {
Wakelock.disable(); WakelockPlus.disable();
widget.onVideoEnded(); widget.onVideoEnded();
} }
} }
@@ -155,8 +155,8 @@ class _VideoPlayerState extends State<VideoPlayer> {
Future<void> initializePlayer() async { Future<void> initializePlayer() async {
try { try {
videoPlayerController = widget.file == null videoPlayerController = widget.file == null
? VideoPlayerController.network( ? VideoPlayerController.networkUrl(
widget.url!, Uri.parse(widget.url!),
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
) )
: VideoPlayerController.file(widget.file!); : VideoPlayerController.file(widget.file!);
@@ -210,8 +210,7 @@ class _VideoPlayerState extends State<VideoPlayer> {
child: Center( child: Center(
child: Stack( child: Stack(
children: [ children: [
if (widget.placeholder != null) if (widget.placeholder != null) widget.placeholder!,
widget.placeholder!,
const Center( const Center(
child: ImmichLoadingIndicator(), child: ImmichLoadingIndicator(),
), ),

View File

@@ -90,7 +90,7 @@ class BackgroundService {
requireUnmetered, requireUnmetered,
requireCharging, requireCharging,
triggerUpdateDelay, triggerUpdateDelay,
triggerMaxDelay triggerMaxDelay,
], ],
); );
return ok; return ok;

View File

@@ -511,7 +511,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith( state = state.copyWith(
selectedAlbumsBackupAssetsIds: { selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds, ...state.selectedAlbumsBackupAssetsIds,
deviceAssetId deviceAssetId,
}, },
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
); );

View File

@@ -149,16 +149,30 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
} }
Future<bool> _startUpload(Iterable<Asset> allManualUploads) async { Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
bool hasErrors = false;
try { try {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress); _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) { if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
Set<AssetEntity> allUploadAssets = allManualUploads // We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
.where((e) => e.isLocal && e.local != null) // where platform specific fields such as `subtype` used to detect platform specific assets such as
.map((e) => e.local!) // LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
.toSet(); List<AssetEntity?> allAssetsFromDevice = await Future.wait(
allManualUploads
// Filter local only assets
.where((e) => e.isLocal && !e.isRemote)
.map((e) => e.local!.obtainForNewProperties()),
);
if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning(
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
);
}
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
if (allUploadAssets.isEmpty) { if (allUploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process"); debugPrint("[_startUpload] No Assets to upload - Abort Process");
@@ -213,7 +227,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
'[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},' '[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
' failed: ${state.totalAssetsToUpload - state.successfulUploads}', ' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
); );
bool hasErrors = false;
// User cancelled upload // User cancelled upload
if (!ok && state.cancelToken.isCancelled) { if (!ok && state.cancelToken.isCancelled) {
await _localNotificationService.showOrUpdateManualUploadStatus( await _localNotificationService.showOrUpdateManualUploadStatus(
@@ -237,32 +251,29 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
presentBanner: true, presentBanner: true,
); );
} }
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
_handleAppInActivity();
await _backupProvider.notifyBackgroundServiceCanRun();
return !hasErrors;
} else { } else {
openAppSettings(); openAppSettings();
debugPrint("[_startUpload] Do not have permission to the gallery"); debugPrint("[_startUpload] Do not have permission to the gallery");
} }
} catch (e) { } catch (e) {
debugPrint("ERROR _startUpload: ${e.toString()}"); debugPrint("ERROR _startUpload: ${e.toString()}");
} hasErrors = true;
} finally {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
_handleAppInActivity(); _handleAppInActivity();
await _localNotificationService.closeNotification( await _localNotificationService.closeNotification(
LocalNotificationService.manualUploadDetailedNotificationID, LocalNotificationService.manualUploadDetailedNotificationID,
); );
await _backupProvider.notifyBackgroundServiceCanRun(); await _backupProvider.notifyBackgroundServiceCanRun();
return false; }
return !hasErrors;
} }
void _handleAppInActivity() { void _handleAppInActivity() {
final appState = ref.read(appStateProvider.notifier).getAppState(); final appState = ref.read(appStateProvider.notifier).getAppState();
// The app is currently in background. Perform the necessary cleanups which // The app is currently in background. Perform the necessary cleanups which
// are on-hold for upload completion // are on-hold for upload completion
if (appState != AppStateEnum.active || appState != AppStateEnum.resumed) { if (appState != AppStateEnum.active && appState != AppStateEnum.resumed) {
ref.read(appStateProvider.notifier).handleAppInactivity(); ref.read(appStateProvider.notifier).handleAppInactivity();
} }
} }

View File

@@ -248,9 +248,9 @@ class BackupService {
req.fields['deviceAssetId'] = entity.id; req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId; req.fields['deviceId'] = deviceId;
req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String(); req.fields['fileCreatedAt'] = entity.createDateTime.toUtc().toIso8601String();
req.fields['fileModifiedAt'] = req.fields['fileModifiedAt'] =
entity.modifiedDateTime.toIso8601String(); entity.modifiedDateTime.toUtc().toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString(); req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['duration'] = entity.videoDuration.toString(); req.fields['duration'] = entity.videoDuration.toString();

View File

@@ -174,7 +174,7 @@ class AlbumInfoCard extends HookConsumerWidget {
bottom: 10, bottom: 10,
right: 25, right: 25,
child: buildSelectedTextBox(), child: buildSelectedTextBox(),
) ),
], ],
), ),
), ),
@@ -218,7 +218,7 @@ class AlbumInfoCard extends HookConsumerWidget {
}), }),
future: albumInfo.assetCount, future: albumInfo.assetCount,
), ),
) ),
], ],
), ),
), ),

View File

@@ -212,7 +212,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
Text( Text(
" ${uploadProgress.toStringAsFixed(0)}%", " ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
) ),
], ],
), ),
), ),

View File

@@ -247,7 +247,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Wrap( child: Wrap(
children: [ children: [
...buildSelectedAlbumNameChip(), ...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip() ...buildExcludedAlbumNameChip(),
], ],
), ),
), ),
@@ -301,7 +301,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
.watch(backupProvider) .watch(backupProvider)
.availableAlbums .availableAlbums
.length .length
.toString() .toString(),
], ],
), ),
style: const TextStyle( style: const TextStyle(

View File

@@ -26,7 +26,7 @@ import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock/wakelock.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
class BackupControllerPage extends HookConsumerWidget { class BackupControllerPage extends HookConsumerWidget {
const BackupControllerPage({Key? key}) : super(key: key); const BackupControllerPage({Key? key}) : super(key: key);
@@ -114,7 +114,7 @@ class BackupControllerPage extends HookConsumerWidget {
); );
return; return;
} }
Wakelock.enable(); WakelockPlus.enable();
const limit = 100; const limit = 100;
final toDelete = await ref final toDelete = await ref
.read(backupVerificationServiceProvider) .read(backupVerificationServiceProvider)
@@ -140,7 +140,7 @@ class BackupControllerPage extends HookConsumerWidget {
); );
} }
} finally { } finally {
Wakelock.disable(); WakelockPlus.disable();
checkInProgress.value = false; checkInProgress.value = false;
} }
} }
@@ -202,7 +202,7 @@ class BackupControllerPage extends HookConsumerWidget {
child: const Text('backup_controller_page_storage_format').tr( child: const Text('backup_controller_page_storage_format').tr(
args: [ args: [
backupState.serverInfo.diskUse, backupState.serverInfo.diskUse,
backupState.serverInfo.diskSize backupState.serverInfo.diskSize,
], ],
), ),
), ),
@@ -256,7 +256,7 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
), ),
), ),
) ),
], ],
), ),
), ),
@@ -624,7 +624,7 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontSize: 12), style: TextStyle(fontSize: 12),
).tr(), ).tr(),
buildSelectedAlbumName(), buildSelectedAlbumName(),
buildExcludedAlbumName() buildExcludedAlbumName(),
], ],
), ),
), ),
@@ -776,7 +776,7 @@ class BackupControllerPage extends HookConsumerWidget {
const Divider(), const Divider(),
const CurrentUploadingAssetInfoBox(), const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(), if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton() buildBackupButton(),
], ],
), ),
), ),

View File

@@ -129,7 +129,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
], ],
), ),
), ),
) ),
], ],
), ),
), ),

View File

@@ -83,7 +83,7 @@ class FavoritesPage extends HookConsumerWidget {
style: TextStyle(fontSize: 14), style: TextStyle(fontSize: 14),
), ),
onTap: processing.value ? null : unfavorite, onTap: processing.value ? null : unfavorite,
) ),
], ],
), ),
), ),
@@ -108,7 +108,7 @@ class FavoritesPage extends HookConsumerWidget {
selectionActive: selectionEnabledHook.value, selectionActive: selectionEnabledHook.value,
listener: selectionListener, listener: selectionListener,
), ),
if (selectionEnabledHook.value) buildBottomBar() if (selectionEnabledHook.value) buildBottomBar(),
], ],
), ),
), ),

View File

@@ -57,7 +57,7 @@ class GroupDividerTitle extends ConsumerWidget {
Icons.check_circle_outline_rounded, Icons.check_circle_outline_rounded,
color: Colors.grey, color: Colors.grey,
), ),
) ),
], ],
), ),
); );

View File

@@ -89,7 +89,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
perRow.value = 7 - scaleFactor.value.toInt(); perRow.value = 7 - scaleFactor.value.toInt();
} }
}; };
}) }),
}, },
child: ImmichAssetGridView( child: ImmichAssetGridView(
onRefresh: onRefresh, onRefresh: onRefresh,

View File

@@ -225,7 +225,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
right: i + 1 == num ? 0.0 : widget.margin, right: i + 1 == num ? 0.0 : widget.margin,
), ),
color: Colors.grey, color: Colors.grey,
) ),
], ],
); );
} }
@@ -300,7 +300,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
} }
Text _labelBuilder(int pos) { Text _labelBuilder(int pos) {
final date = widget.renderList.elements[pos].date; final maxLength = widget.renderList.elements.length;
if (pos < 0 || pos >= maxLength) {
return const Text("");
}
final date = widget.renderList.elements[pos % maxLength].date;
return Text( return Text(
DateFormat.yMMMM().format(date), DateFormat.yMMMM().format(date),
style: const TextStyle( style: const TextStyle(
@@ -335,7 +341,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
itemBuilder: _itemBuilder, itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0), itemCount: widget.renderList.elements.length +
(widget.topWidget != null ? 1 : 0),
addRepaintBoundaries: true, addRepaintBoundaries: true,
); );

View File

@@ -155,7 +155,7 @@ class ControlBottomAppBar extends ConsumerWidget {
if (hasRemote) if (hasRemote)
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: SizedBox(height: 200), child: SizedBox(height: 200),
) ),
], ],
), ),
); );

View File

@@ -1,7 +1,8 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -29,9 +30,9 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
backupState.backgroundBackup || backupState.autoBackup; backupState.backgroundBackup || backupState.autoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider); final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider); AuthenticationState authState = ref.watch(authenticationProvider);
final user = Store.tryGet(StoreKey.currentUser);
buildProfilePhoto() { buildProfilePhoto() {
if (authState.profileImagePath.isEmpty) { if (authState.profileImagePath.isEmpty || user == null) {
return IconButton( return IconButton(
splashRadius: 25, splashRadius: 25,
icon: const Icon( icon: const Icon(
@@ -47,9 +48,10 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
onTap: () { onTap: () {
Scaffold.of(context).openDrawer(); Scaffold.of(context).openDrawer();
}, },
child: const UserCircleAvatar( child: UserCircleAvatar(
radius: 18, radius: 18,
size: 33, size: 33,
user: user,
), ),
); );
} }

View File

@@ -108,7 +108,7 @@ class ProfileDrawer extends HookConsumerWidget {
buildSignOutButton(), buildSignOutButton(),
], ],
), ),
const ServerInfoBox() const ServerInfoBox(),
], ],
), ),
); );

View File

@@ -3,7 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -19,14 +20,10 @@ class ProfileDrawerHeader extends HookConsumerWidget {
final uploadProfileImageStatus = final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status; ref.watch(uploadProfileImageProvider).status;
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final user = Store.tryGet(StoreKey.currentUser);
buildUserProfileImage() { buildUserProfileImage() {
var userImage = const UserCircleAvatar( if (authState.profileImagePath.isEmpty || user == null) {
radius: 35,
size: 66,
);
if (authState.profileImagePath.isEmpty) {
return const CircleAvatar( return const CircleAvatar(
radius: 35, radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'), backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
@@ -34,6 +31,12 @@ class ProfileDrawerHeader extends HookConsumerWidget {
); );
} }
var userImage = UserCircleAvatar(
radius: 35,
size: 66,
user: user,
);
if (uploadProfileImageStatus == UploadProfileStatus.idle) { if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) { if (authState.profileImagePath.isNotEmpty) {
return userImage; return userImage;
@@ -153,7 +156,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
Text( Text(
authState.userEmail, authState.userEmail,
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
) ),
], ],
), ),
); );

View File

@@ -1,44 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class UserCircleAvatar extends ConsumerWidget {
final double radius;
final double size;
const UserCircleAvatar({super.key, required this.radius, required this.size});
@override
Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState authState = ref.watch(authenticationProvider);
var profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${authState.userId}?d=${Random().nextInt(1024)}';
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: radius,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage(
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
width: size,
height: size,
image: NetworkImage(
profileImageUrl,
headers: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
},
),
fadeInDuration: const Duration(milliseconds: 200),
imageErrorBuilder: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
),
),
);
}
}

View File

@@ -221,7 +221,7 @@ class HomePage extends HookConsumerWidget {
namedArgs: { namedArgs: {
"album": album.name, "album": album.name,
"added": result.successfullyAdded.toString(), "added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString() "failed": result.alreadyInAlbum.length.toString(),
}, },
), ),
); );
@@ -323,7 +323,7 @@ class HomePage extends HookConsumerWidget {
).tr(), ).tr(),
), ),
), ),
) ),
], ],
), ),
); );
@@ -365,7 +365,7 @@ class HomePage extends HookConsumerWidget {
enabled: !processing.value, enabled: !processing.value,
selectionAssetState: selectionAssetState.value, selectionAssetState: selectionAssetState.value,
), ),
if (processing.value) const Center(child: ImmichLoadingIndicator()) if (processing.value) const Center(child: ImmichLoadingIndicator()),
], ],
), ),
); );

View File

@@ -94,7 +94,7 @@ class ChangePasswordForm extends HookConsumerWidget {
), ),
], ],
), ),
) ),
], ],
), ),
), ),

View File

@@ -110,7 +110,7 @@ class MemoryCard extends HookConsumerWidget {
left: 18.0, left: 18.0,
bottom: 18.0, bottom: 18.0,
child: buildTitle(), child: buildTitle(),
) ),
], ],
), ),
); );

View File

@@ -153,6 +153,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
child = buildRequestPermission(); child = buildRequestPermission();
break; break;
case PermissionStatus.granted: case PermissionStatus.granted:
case PermissionStatus.provisional:
child = buildPermissionGranted(); child = buildPermissionGranted();
break; break;
case PermissionStatus.restricted: case PermissionStatus.restricted:

View File

@@ -44,7 +44,7 @@ class PartnerPage extends HookConsumerWidget {
Text("${u.firstName} ${u.lastName}"), Text("${u.firstName} ${u.lastName}"),
], ],
), ),
) ),
], ],
); );
}, },
@@ -151,7 +151,7 @@ class PartnerPage extends HookConsumerWidget {
availableUsers.whenOrNull(data: (data) => addNewUsersHandler), availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add), icon: const Icon(Icons.person_add),
tooltip: "partner_page_add_partner".tr(), tooltip: "partner_page_add_partner".tr(),
) ),
], ],
), ),
body: buildUserList(partners), body: buildUserList(partners),

View File

@@ -50,7 +50,7 @@ class CuratedPeopleRow extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final person = content[index]; final person = content[index];
final headers = { final headers = {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}" "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
}; };
return Padding( return Padding(
padding: const EdgeInsets.only(right: 18.0), padding: const EdgeInsets.only(right: 18.0),
@@ -102,7 +102,7 @@ class CuratedPeopleRow extends StatelessWidget {
fontSize: 13.0, fontSize: 13.0,
), ),
), ),
) ),
], ],
), ),
), ),

View File

@@ -39,7 +39,7 @@ class SearchSuggestionList extends ConsumerWidget {
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
) ),
], ],
), ),
), ),

View File

@@ -46,7 +46,7 @@ class ThumbnailWithInfo extends StatelessWidget {
imageUrl: imageUrl!, imageUrl: imageUrl!,
httpHeaders: { httpHeaders: {
"Authorization": "Authorization":
"Bearer ${Store.get(StoreKey.accessToken)}" "Bearer ${Store.get(StoreKey.accessToken)}",
}, },
errorWidget: (context, url, error) => errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined), const Icon(Icons.image_not_supported_outlined),

View File

@@ -56,7 +56,7 @@ class PersonResultPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
onTap: showEditNameDialog, onTap: showEditNameDialog,
) ),
], ],
), ),
); );
@@ -134,7 +134,7 @@ class PersonResultPage extends HookConsumerWidget {
getFaceThumbnailUrl(personId), getFaceThumbnailUrl(personId),
headers: { headers: {
"Authorization": "Authorization":
"Bearer ${isar_store.Store.get(isar_store.StoreKey.accessToken)}" "Bearer ${isar_store.Store.get(isar_store.StoreKey.accessToken)}",
}, },
), ),
), ),

View File

@@ -42,7 +42,7 @@ class SettingsPage extends HookConsumerWidget {
const AssetListSettings(), const AssetListSettings(),
const NotificationSetting(), const NotificationSetting(),
// const ExperimentalSettings(), // const ExperimentalSettings(),
const AdvancedSettings() const AdvancedSettings(),
], ],
).toList(), ).toList(),
], ],

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/views/album_options_part.dart';
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart'; import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart'; import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart'; import 'package:immich_mobile/modules/album/views/create_album_page.dart';
@@ -74,7 +75,7 @@ part 'router.gr.dart';
AutoRoute(page: HomePage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: HomePage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SearchPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SearchPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharingPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SharingPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: LibraryPage, guards: [AuthGuard, DuplicateGuard]) AutoRoute(page: LibraryPage, guards: [AuthGuard, DuplicateGuard]),
], ],
transitionsBuilder: TransitionsBuilders.fadeIn, transitionsBuilder: TransitionsBuilders.fadeIn,
), ),
@@ -152,6 +153,7 @@ part 'router.gr.dart';
), ),
AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
], ],
) )
class AppRouter extends _$AppRouter { class AppRouter extends _$AppRouter {

View File

@@ -296,6 +296,16 @@ class _$AppRouter extends RootStackRouter {
), ),
); );
}, },
AlbumOptionsRoute.name: (routeData) {
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: AlbumOptionsPage(
key: args.key,
album: args.album,
),
);
},
HomeRoute.name: (routeData) { HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, routeData: routeData,
@@ -595,6 +605,14 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard, duplicateGuard,
], ],
), ),
RouteConfig(
AlbumOptionsRoute.name,
path: '/album-options-page',
guards: [
authGuard,
duplicateGuard,
],
),
]; ];
} }
@@ -1319,6 +1337,40 @@ class MemoryRouteArgs {
} }
} }
/// generated route for
/// [AlbumOptionsPage]
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
AlbumOptionsRoute({
Key? key,
required Album album,
}) : super(
AlbumOptionsRoute.name,
path: '/album-options-page',
args: AlbumOptionsRouteArgs(
key: key,
album: album,
),
);
static const String name = 'AlbumOptionsRoute';
}
class AlbumOptionsRouteArgs {
const AlbumOptionsRouteArgs({
this.key,
required this.album,
});
final Key? key;
final Album album;
@override
String toString() {
return 'AlbumOptionsRouteArgs{key: $key, album: $album}';
}
}
/// generated route for /// generated route for
/// [HomePage] /// [HomePage]
class HomeRoute extends PageRouteInfo<void> { class HomeRoute extends PageRouteInfo<void> {

View File

@@ -1,7 +1,7 @@
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ServerInfoState { class ServerInfoState {
final ServerVersionReponseDto serverVersion; final ServerVersionResponseDto serverVersion;
final bool isVersionMismatch; final bool isVersionMismatch;
final String versionMismatchErrorMessage; final String versionMismatchErrorMessage;
@@ -12,7 +12,7 @@ class ServerInfoState {
}); });
ServerInfoState copyWith({ ServerInfoState copyWith({
ServerVersionReponseDto? serverVersion, ServerVersionResponseDto? serverVersion,
bool? isVersionMismatch, bool? isVersionMismatch,
String? versionMismatchErrorMessage, String? versionMismatchErrorMessage,
}) { }) {

View File

@@ -157,7 +157,7 @@ User _userDeserialize(
isPartnerSharedBy: reader.readBoolOrNull(offsets[4]) ?? false, isPartnerSharedBy: reader.readBoolOrNull(offsets[4]) ?? false,
isPartnerSharedWith: reader.readBoolOrNull(offsets[5]) ?? false, isPartnerSharedWith: reader.readBoolOrNull(offsets[5]) ?? false,
lastName: reader.readString(offsets[6]), lastName: reader.readString(offsets[6]),
memoryEnabled: reader.readBoolOrNull(offsets[7]) ?? true, memoryEnabled: reader.readBoolOrNull(offsets[7]),
profileImagePath: reader.readStringOrNull(offsets[8]) ?? '', profileImagePath: reader.readStringOrNull(offsets[8]) ?? '',
updatedAt: reader.readDateTime(offsets[9]), updatedAt: reader.readDateTime(offsets[9]),
); );
@@ -186,7 +186,7 @@ P _userDeserializeProp<P>(
case 6: case 6:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
case 7: case 7:
return (reader.readBoolOrNull(offset) ?? true) as P; return (reader.readBoolOrNull(offset)) as P;
case 8: case 8:
return (reader.readStringOrNull(offset) ?? '') as P; return (reader.readStringOrNull(offset) ?? '') as P;
case 9: case 9:
@@ -979,8 +979,24 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
}); });
} }
QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'memoryEnabled',
));
});
}
QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'memoryEnabled',
));
});
}
QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledEqualTo( QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledEqualTo(
bool value) { bool? value) {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo( return query.addFilterCondition(FilterCondition.equalTo(
property: r'memoryEnabled', property: r'memoryEnabled',
@@ -1661,7 +1677,7 @@ extension UserQueryProperty on QueryBuilder<User, User, QQueryProperty> {
}); });
} }
QueryBuilder<User, bool, QQueryOperations> memoryEnabledProperty() { QueryBuilder<User, bool?, QQueryOperations> memoryEnabledProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'memoryEnabled'); return query.addPropertyName(r'memoryEnabled');
}); });

View File

@@ -0,0 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
final isAdminProvider = Provider<bool>((ref) {
final currentUser = ref.watch(currentUserProvider);
return currentUser?.isAdmin ?? false; // Default to non-admin if no user
});

View File

@@ -21,6 +21,7 @@ enum AppStateEnum {
paused, paused,
resumed, resumed,
detached, detached,
hidden,
} }
class AppStateNotiifer extends StateNotifier<AppStateEnum> { class AppStateNotiifer extends StateNotifier<AppStateEnum> {
@@ -84,6 +85,10 @@ class AppStateNotiifer extends StateNotifier<AppStateEnum> {
state = AppStateEnum.detached; state = AppStateEnum.detached;
ref.watch(manualUploadProvider.notifier).cancelBackup(); ref.watch(manualUploadProvider.notifier).cancelBackup();
} }
void handleAppHidden() {
state = AppStateEnum.hidden;
}
} }
final appStateProvider = final appStateProvider =

View File

@@ -10,7 +10,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
ServerInfoNotifier(this._serverInfoService) ServerInfoNotifier(this._serverInfoService)
: super( : super(
ServerInfoState( ServerInfoState(
serverVersion: ServerVersionReponseDto( serverVersion: ServerVersionResponseDto(
major: 0, major: 0,
patch_: 0, patch_: 0,
minor: 0, minor: 0,
@@ -23,7 +23,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
final ServerInfoService _serverInfoService; final ServerInfoService _serverInfoService;
getServerVersion() async { getServerVersion() async {
ServerVersionReponseDto? serverVersion = ServerVersionResponseDto? serverVersion =
await _serverInfoService.getServerVersion(); await _serverInfoService.getServerVersion();
if (serverVersion == null) { if (serverVersion == null) {

View File

@@ -69,7 +69,6 @@ class AssetService {
await _apiService.assetApi.getAllAssetsWithETag( await _apiService.assetApi.getAllAssetsWithETag(
eTag: etag, eTag: etag,
userId: user.id, userId: user.id,
withoutThumbs: true,
); );
if (assets == null) { if (assets == null) {
return null; return null;

View File

@@ -102,7 +102,7 @@ class LocalNotificationService {
cancelUploadActionID, cancelUploadActionID,
'Cancel', 'Cancel',
showsUserInterface: true, showsUserInterface: true,
) ),
] ]
: null, : null,
) )

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