mirror of
https://github.com/immich-app/immich.git
synced 2025-12-17 10:07:51 -08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc1fecfffd | ||
|
|
e02817362c | ||
|
|
1b0484fc46 | ||
|
|
6fe214a784 | ||
|
|
e18a9f84a4 | ||
|
|
59bb727636 | ||
|
|
20e0c03b39 | ||
|
|
6d1567cf44 | ||
|
|
dc3f53a973 | ||
|
|
dad7cf47b4 | ||
|
|
165b91b068 | ||
|
|
8211afb726 | ||
|
|
2cccef174a | ||
|
|
9bbef4a97b | ||
|
|
10c2bda3a9 | ||
|
|
cf9e04c8ec | ||
|
|
d6887117ac | ||
|
|
3b11be2859 | ||
|
|
d7f52739e8 | ||
|
|
71ea46d95e | ||
|
|
e2afc43506 | ||
|
|
6aed1180e7 | ||
|
|
476b735e3c | ||
|
|
7ad12c7f33 | ||
|
|
60729a091a | ||
|
|
d2bad1d553 | ||
|
|
3e31ad51be | ||
|
|
fbeb4664f7 | ||
|
|
4ee8a30a5a | ||
|
|
6243bce46c | ||
|
|
98b72fdb9b | ||
|
|
5e901e4d21 | ||
|
|
66490d5db4 | ||
|
|
2b839088c7 | ||
|
|
28d3d3e679 | ||
|
|
2de30e34f4 | ||
|
|
2ff71b0d27 | ||
|
|
cdb45364c3 | ||
|
|
8ba338fbe1 | ||
|
|
ce84f9c755 | ||
|
|
d1e74a28d9 | ||
|
|
78a2a9e666 | ||
|
|
53f5643994 | ||
|
|
4ee634766d | ||
|
|
bab739efbd | ||
|
|
8568ec838a | ||
|
|
4cbb18aabc | ||
|
|
3fb60aca4f | ||
|
|
19bbdebdf7 | ||
|
|
bc66b1a556 | ||
|
|
4762fd83d4 | ||
|
|
c27c12d975 | ||
|
|
0abbd85134 | ||
|
|
af1f00dff9 | ||
|
|
35b4c9d375 | ||
|
|
74da15e20d | ||
|
|
efc7fdb669 | ||
|
|
a75f368d5b |
2
.github/workflows/build-mobile.yml
vendored
2
.github/workflows/build-mobile.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -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
|
||||||
|
|||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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`
|
|
||||||
|
|||||||
844
cli/src/api/open-api/api.ts
generated
844
cli/src/api/open-api/api.ts
generated
File diff suppressed because it is too large
Load Diff
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
@@ -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).
|
||||||
|
|||||||
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
@@ -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).
|
||||||
|
|||||||
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
@@ -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).
|
||||||
|
|||||||
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)?
|
||||||
|
|
||||||
|
|||||||
91
docs/docs/install/config-file.md
Normal file
91
docs/docs/install/config-file.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
sidebar_position: 100
|
sidebar_position: 80
|
||||||
---
|
---
|
||||||
|
|
||||||
import RegisterAdminUser from '../partials/_register-admin.md';
|
import RegisterAdminUser from '../partials/_register-admin.md';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1739
machine-learning/poetry.lock
generated
1739
machine-learning/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
2
machine-learning/requirements.txt
Normal file
2
machine-learning/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# requirements to be installed with `--no-deps` flag
|
||||||
|
clip-server==0.8.*
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"flutterSdkVersion": "3.10.5",
|
"flutterSdkVersion": "3.13.0",
|
||||||
"flavors": {}
|
"flavors": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
205
mobile/lib/modules/album/views/album_options_part.dart
Normal file
205
mobile/lib/modules/album/views/album_options_part.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class BackgroundService {
|
|||||||
requireUnmetered,
|
requireUnmetered,
|
||||||
requireCharging,
|
requireCharging,
|
||||||
triggerUpdateDelay,
|
triggerUpdateDelay,
|
||||||
triggerMaxDelay
|
triggerMaxDelay,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return ok;
|
return ok;
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class GroupDividerTitle extends ConsumerWidget {
|
|||||||
Icons.check_circle_outline_rounded,
|
Icons.check_circle_outline_rounded,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
if (hasRemote)
|
if (hasRemote)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: SizedBox(height: 200),
|
child: SizedBox(height: 200),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||||||
buildSignOutButton(),
|
buildSignOutButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const ServerInfoBox()
|
const ServerInfoBox(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class ChangePasswordForm extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class MemoryCard extends HookConsumerWidget {
|
|||||||
left: 18.0,
|
left: 18.0,
|
||||||
bottom: 18.0,
|
bottom: 18.0,
|
||||||
child: buildTitle(),
|
child: buildTitle(),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class SearchSuggestionList extends ConsumerWidget {
|
|||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)}",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
7
mobile/lib/shared/providers/admin_provider.dart
Normal file
7
mobile/lib/shared/providers/admin_provider.dart
Normal 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
|
||||||
|
});
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user