Compare commits

...

47 Commits

Author SHA1 Message Date
Alex Tran
9c01ca1080 Added correct page title for admin sub pages 2022-12-10 09:23:02 -06:00
Alex Tran
09103dc981 Fixed upsert new DeviceInfo with null isAutoBackup property 2022-12-10 08:36:21 -06:00
Alex Tran
f096910abc Fix release note 2022-12-09 21:54:58 -06:00
Jason Rasmussen
242165485d fix(server): unique email database constraint (#1082) 2022-12-09 21:16:25 -06:00
Alex Tran
e6904ca884 api spec sync 2022-12-09 21:15:53 -06:00
Jason Rasmussen
5a792cc821 chore(docs): spelling (#1081) 2022-12-09 18:39:50 -05:00
Alex
0633eaf68c Pump 2022-12-09 15:19:22 -06:00
Jason Rasmussen
40afa3695a docs: server commands (#1079) 2022-12-09 14:54:19 -06:00
Jason Rasmussen
14889e7d85 fix(server): require local admin account (#1070) 2022-12-09 14:53:11 -06:00
Jason Rasmussen
3bb103c6b6 fix(server): link 'immich' (#1080) 2022-12-09 14:52:56 -06:00
Jason Rasmussen
5e680551b9 feat(server,web): migrate oauth settings from env to system config (#1061) 2022-12-09 14:51:42 -06:00
Jason Rasmussen
cefdd86b7f refactor(server): device info service (#1071)
* refactor(server): device info service

* use upsertDeviceInfo in mobile app

* fix: return types and dedupe code

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
2022-12-08 09:57:07 -06:00
Matt
b8e26a2112 unraid guide no longer external (#1074)
Co-authored-by: Matt Farrell <mfarrell@squareup.com>
2022-12-08 09:54:06 -06:00
Jason Rasmussen
58a149990d refactor(server): server version logging (#1073)
* refactor(server): server version logging

* chore: server => microservices
2022-12-08 09:53:18 -06:00
Fynn Petersen-Frey
c23b2479f7 feat(mobile): configurable background backup delay (#1068)
let's the user configure how much to delay the trigger for running the backup whenever assets are changed on the device
2022-12-08 09:51:36 -06:00
bo0tzz
a97b761eda Add generated openapi docs to website (#1067)
* Add generated openapi docs to website

* Uppercase API link in navbar

* fix(docs): open api empty summary (#1069)

* feat(docs): Use /docs/api path for swagger docs

* Sync api version to be the same as the server

* Update version

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-12-07 21:57:34 -06:00
Alex
1adf8ff6b6 chore(web) Update SvelteKit (#1066)
* Update sveltekit

* Update sveltekit

* Update correct preloading attribute
2022-12-06 18:08:08 -06:00
Alex Tran
b5a5363a6a Fixed openapi generation command for mobile 2022-12-06 16:10:04 -06:00
Alex
f91bdc2785 fix(server) added TagResponseDto for TagController (#1065)
* fix(server) added TagResponseDto for TagController

* Added userId to DTO
2022-12-06 15:46:13 -06:00
Alex
db34f2f7fd fix(server) fix correct MIME type for Nikon NEF (#1060) 2022-12-05 12:05:02 -06:00
Alex
5de8ea162d feat(server) Tagging system (#1046) 2022-12-05 11:56:44 -06:00
Alex
6e2763b72c Fix plural 2022-12-05 10:52:21 -06:00
Alex
966d99217a fix(web) fix test (#1059) 2022-12-04 22:51:22 -06:00
Alex
5d140145c1 Update README.md 2022-12-04 17:15:41 -06:00
Alex
fcf3b0b672 Update README_zh_CN.md
Add information about community maintained readme
2022-12-04 17:15:14 -06:00
Matthias Rupp
e8bbad6772 feat(server): Per user asset access control (#993)
* Limit asset access to owner

* Check public albums for asset

* Clean up

* Fix test

* Rename repository method

* Simplify control flow

* Revert "Simplify control flow"

This reverts commit 7bc3cbf687.

* Revert Makefile change
2022-12-04 11:42:36 -06:00
Kiel Hurley
5f2b75997f feat(web): Localize dates and numbers (#1056) 2022-12-04 09:35:20 -06:00
chen3stones
426ce77f1c chore(): add Chinese README file (#1058)
Co-authored-by: chen3stones <chen3stones@outlook.com>
2022-12-03 22:03:29 -06:00
Fynn Petersen-Frey
83c7434eb5 feat(mobile): enable zoom of preview images & reuse cached thumbnails (#1049) 2022-12-03 21:59:39 -06:00
Jason Rasmussen
99854e90be feat(server): link via profile.sub (#1055) 2022-12-03 21:59:24 -06:00
Fynn Petersen-Frey
424b11cf50 feat(mobile): configure detail viewer asset loading (#1044) 2022-12-02 14:55:10 -06:00
Alex Tran
da87b1256c Release mobile hotfix 2022-12-01 23:16:22 -06:00
Alex
a3971543b5 fix(mobile): Start up from splash screen does not trigger foreground backup (#1042) 2022-12-01 09:20:53 -06:00
Alex
a384798779 Up version for release 2022-11-30 11:18:06 -06:00
Alex
d31eddf32f chore(mobile) Improve mobile UI (#1038) 2022-11-30 10:58:07 -06:00
Fynn Petersen-Frey
1068c4ad23 feat(server,web): activate ETags for all API endpoints and asset serving (#1031)
This greatly reduces the network traffic by app/web.
2022-11-29 15:45:47 -06:00
Alex
cbc979263e chore(mobile): Improve readability of logs page (#1033) 2022-11-28 14:14:22 -06:00
Fynn Petersen-Frey
765181bbc0 chore(mobile): improve CSV log export (#1032) 2022-11-28 10:17:27 -06:00
Fynn Petersen-Frey
d82dec9773 fix(mobile): fix cache invalidation on logout (#1030)
await all the cache-invalidation operations during logout and catch errors to actually perform all operations.
2022-11-28 10:01:09 -06:00
Alex
024177515d feat(mobile) Add in app logging to show app's log information (#1014) 2022-11-27 14:34:19 -06:00
Alex Tran
fb3b36a569 Added test for user.service 2022-11-26 15:09:06 -06:00
Alex
614743c8f4 fix(server): Prevent delete admin user (#1023) 2022-11-26 15:02:23 -06:00
Fynn Petersen-Frey
47f5e4134e feat(mobile): use cached asset info if unchanged instead of downloading all assets (#1017)
* feat(mobile): use cached asset info if unchanged instead of downloading all assets

This adds an HTTP ETag to the getAllAssets endpoint and client-side support in the app.
If locally cache content is identical to the content on the server, the potentially large list of all assets does not need to be downloaded.

* use ts import instead of require
2022-11-26 10:16:02 -06:00
denck007
efa7b3ba54 Fix(web): navbar color overlap and scroll bar incorrect z index (#1018)
* fix(web): Navbar color overlaps tall images

* fix(web): Scroll bar date behind navbar when scrubbing (fixes issue #757)
2022-11-25 20:52:01 -06:00
Alex
1e9d67ec39 Up mobile version for hotfix release 2022-11-24 15:50:18 -06:00
Alex
80d0ddca9a fix(mobile): Fix not able to show device asset on Android 13 (#1016) 2022-11-24 15:47:55 -06:00
Kiel Hurley
976d347623 feat(server,web,mobile): Use binary prefixes for data sizes (#1009) 2022-11-24 11:39:27 -06:00
310 changed files with 16520 additions and 7401 deletions

13
.gitattributes vendored Normal file
View File

@@ -0,0 +1,13 @@
mobile/openapi/**/*.md -diff -merge
mobile/openapi/**/*.md linguist-generated=true
mobile/openapi/**/*.dart -diff -merge
mobile/openapi/**/*.dart linguist-generated=true
web/src/api/open-api/**/*.md -diff -merge
web/src/api/open-api/**/*.md linguist-generated=true
web/src/api/open-api/**/*.ts -diff -merge
web/src/api/open-api/**/*.ts linguist-generated=true
mobile/openapi/.openapi-generator/FILES -diff -merge
mobile/openapi/.openapi-generator/FILES linguist-generated=true

View File

@@ -4,6 +4,9 @@ dev:
dev-new:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new-update:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-update:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
@@ -26,4 +29,7 @@ prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate
cd ./server && npm run api:generate
attach-server:
docker exec -it docker_immich-server_1 sh

View File

@@ -1,9 +0,0 @@
# TODO
Server scenario with web
[ ] 1 user exist without admin right -> make admin on first check
[ ] 2 users exist without admin right -> ask user to choose which account will be the admin
[ X ] No users exist -> prompt signup form for Admin

View File

@@ -17,6 +17,9 @@
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README_zh_CN.md">中文</a>
</p>
## Disclaimer

115
README_zh_CN.md Normal file
View File

@@ -0,0 +1,115 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - 高性能的自托管照片和视频备份方案</h3>
<p align="center">
请注意: 此README不是由Immich团队维护, 这意味着它在某一时间点不会被更新,因为我们是依靠贡献者来更新的。感谢理解。
</p>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
</p>
## 免责声明
- ⚠️ 本项目正在 **非常活跃** 的开发中。
- ⚠️ 可能存在bug或者重大变更。
- ⚠️ **不要把本软件作为你存储照片或视频的唯一方式!**
## 目录
- [官方文档](https://immich.app/docs/overview/introduction)
- [示例](#示例)
- [功能特性](#功能特性)
- [介绍](https://immich.app/docs/overview/introduction)
- [安装](https://immich.app/docs/installation/requirements)
- [贡献指南](https://immich.app/docs/contribution-guidelines)
- [支持本项目](#support-the-project)
- [已知问题](#known-issues)
## 官方文档
你可以在 https://immich.app/ 找到包含安装手册的官方文档.
## 示例
你可以在 https://demo.immich.app 访问示例.
在移动端, 你可以使用 `https://demo.immich.app/api`获取`服务终端链接`
```bash title="示例认证信息"
认证信息
邮箱: demo@immich.app
密码: demo
```
```
规格: 甲骨文免费虚拟机套餐-阿姆斯特丹 4核 2.4Ghz ARM64 CPU, 24GB RAM。
```
# 功能特性
| 功能特性 | 移动端 | 网页端 |
| ------------------------------------------- | ------- | --- |
| 上传并查看照片和视频 | 是 | 是 |
| 软件运行时自动备份 | 是 | N/A |
| 选择需要备份的相册 | 是 | N/A |
| 下载照片和视频到本地 | 是 | 是 |
| 多用户支持 | 是 | 是 |
| 相册 | 是 | 是 |
| 共享相册 | 是 | 是 |
| 可拖动的快速导航栏 | 是 | 是 |
| 支持RAW格式 (HEIC, HEIF, DNG, Apple ProRaw) | 是 | 是 |
| 元数据视图 (EXIF, 地图) | 是 | 是 |
| 通过元数据、对象和标签进行搜索 | 是 | No |
| 管理功能 (用户管理) | N/A | 是 |
| 后台备份 | Android | N/A |
| 虚拟滚动 | 是 | 是 |
| OAuth支持 | 是 | 是 |
| 实时照片备份和查看 (仅iOS) | 是 | 是 |
# 支持本项目
我已经致力于本项目并且将我会持续更新文档、新增功能和修复问题。但是我不能一个人走下去,所以我需要你给予我走下去的动力。
就像我主页里面 [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
- [一次捐赠](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via Github Sponsors
# 已知问题
## 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`

View File

@@ -13,7 +13,13 @@ sidebar_position: 6
| ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device |
### How can I sync an existing directory with Immich's server?
Immich doesn't have the mechanism to sync an existing directory with the server. There is however, a helper CLI tool to help you bulk upload the existing photos and videos to the server. You can find the guide to use the CLI tool [here](/docs/usage/bulk-upload.md).
### Why doesn't Immich watch an existing photo gallery directory?
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### How can I reset the admin password?
The admin password can be reset by running the [reset-admin-password](/docs/usage/server-commands) command on the immich-server.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -4,12 +4,104 @@ sidebar_position: 5
# Unraid
Install Immich on Unraid.
Install Immich on Unraid using the [Docker Compose Manager](https://forums.unraid.net/topic/114415-plugin-docker-compose-manager/) plugin from the Unraid Community Apps.
:::info
- Guide was written using Unraid v6.11.1
- Requires you to have installed the plugin: [Docker Compose Manager](https://forums.unraid.net/topic/114415-plugin-docker-compose-manager/)
- An Unraid share created for your images
- There has been a [report](https://forums.unraid.net/topic/130006-errortraps-traps-node27707-trap-invalid-opcode-ip14fcfc8d03c0-sp7fff32889dd8-more/#comment-1189395) of this not working if your Unraid server doesn't support AVX _(e.g. using a T610)_
:::info Community contribution
Please follow [this community contributed article](https://mfaz.dev/posts/immich-unraid/) to install Immich on Unraid.
:::
1. Go to "**Plugins**" and click on "**Compose.Manager**"
2. Click "**Add New Stack**" and when prompted for a label enter "**Immich**"
<img
src={require('./img/unraid01.webp').default}
width="70%"
alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
/>
3. Select the cog ⚙️ next to Immich then click "**Edit Stack**"
4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml) file into the Unraid editor
<details >
<summary>Using an existing Postgres container? Click me! Otherwise proceed to step 5.</summary>
<ul>
<li>Comment out the database service</li>
<img
src={require('./img/unraid02.png').default}
width="50%"
alt="Comment out database service in the compose file"
/>
<li>Comment out the database dependency for <b>each service</b> <i>(example in screenshot below only shows 2 of the services - ensure you do this for all services)</i></li>
<img
src={require('./img/unraid03.png').default}
width="50%"
alt="Comment out every reference to the database service in the compose file"
/>
<li>Comment out the volumes</li>
<img
src={require('./img/unraid04.png').default}
width="20%"
alt="Comment out database volume"
/>
</ul>
</details>
5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Past the entire contents of the [Immich .env.example](https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example) file into the Unraid editor, then **before saving** edit the following:
- `JWT_SECRET`: Generate a unique secret and paste the value here > Can be generated by either typing `openssl rand -base64 128` in your terminal or copying from [uuidgenerator](https://www.uuidgenerator.net/version1)
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
<img
src={require('./img/unraid05.webp').default}
width="70%"
alt="Absolute location of where you want immich images stored"
/>
<details >
<summary>Using an existing Postgres container? Click me! Otherwise proceed to step 8.</summary>
<p>Update the following database variables as relevant to your Postgres container:</p>
<ul>
<li><code>DB_HOSTNAME</code></li>
<li><code>DB_USERNAME</code></li>
<li><code>DB_PASSWORD</code></li>
<li><code>DB_DATABASE_NAME</code></li>
<li><code>DB_PORT</code></li>
</ul>
</details>
8. Click "**Save Changes**" followed by "**Compose Up**" and Unraid will begin to create the Immich containers in a popup window. Once complete you will see a message on the popup window stating _"Connection Closed"_. Click "**Done**" and go to the Unraid "**Docker**" page
> Note: This can take several minutes depending on your Internet speed and Unraid hardware
9. Once on the Docker page you will see several Immich containers, one of them will be labelled `immich_proxy` and will have a port mapping. Visit the `IP:PORT` displayed in your web browser and you should see the Immich admin setup page.
<img
src={require('./img/unraid06.webp').default}
width="80%"
alt="Go to Docker Tab and visit the address listed next to immich-proxy"
/>
<details >
<summary>Using the Unraid Docker Folders plugin? Click me! Otherwise you're complete!</summary>
<p>If you are using the Docker Folders plugin go the Docker tab and select "<b>New Folder</b>".<br />Label it <i>"Immich"</i> and use the logo from the <a href="https://immich.app/">Immich homepage</a> <i>(right click the logo, "Save As", and reupload to Unraid)</i><br />Then simply select all the Immich related containers before clicking "<b>Submit</b>"</p>
<img
src={require('./img/unraid07.webp').default}
width="80%"
alt="Go to Docker Tab and visit the address listed next to immich-proxy"
/>
<img
src={require('./img/unraid08.webp').default}
width="90%"
alt="Go to Docker Tab and visit the address listed next to immich-proxy"
/>
</details>
:::tip
For more information on how to use the application, please refer to the [Post Installation](/docs/usage/post-installation) guide.
:::
For more information on how to use the application once installed, please refer to the [Post Installation](/docs/usage/post-installation) guide.
:::

View File

@@ -1,5 +1,5 @@
{
"label": "How to use the application",
"label": "Usage",
"position": 3,
"link": {
"type": "generated-index",

View File

@@ -28,13 +28,13 @@ Before enabling OAuth in Immich, a new client application needs to be configured
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
The **Sign-in redirect URIs** should include:
- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
- Mobile app redirect URL `app.immich:/`
* All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
* Mobile app redirect URL `app.immich:/`
:::caution
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
**Authentik example**
<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
@@ -42,17 +42,17 @@ You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobi
## Enable OAuth
Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
| Key | Type | Default | Description |
| Setting | Type | Default | Description |
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) |
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step) |
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
| OAuth enabled | boolean | false | Enable/disable OAuth2 |
| OAuth issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| OAuth client ID | string | (required) | Required. Client ID (from previous step) |
| OAuth client secret | string | (required) | Required. Client Secret (previous step) |
| OAuth scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAuth button text | string | Login with OAuth | Text for the OAuth button on the web |
| OAuth auto register | boolean | true | When true, will automatically register a user the first time they sign in |
:::info
The Issuer URL should look something like the following, and return a valid json document.
@@ -63,14 +63,4 @@ The Issuer URL should look something like the following, and return a valid json
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
:::
Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
```
OAUTH_ENABLED=true
OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
OAUTH_BUTTON_TEXT=Login with Authentik
```
[oidc]: https://openid.net/connect/

View File

@@ -0,0 +1,25 @@
---
sidebar_position: 5
---
# Server Commands
The `immich-server` docker image comes preinstalled with an administrative CLI that supports the following commands:
| Command | Description |
| ----------------------------- | ------------------------------------- |
| `immich help` | Display help |
| `immich reset-admin-password` | Reset the password for the admin user |
## How to run a command
To run a command, connect to the container and then execute it. For example:
```bash
docker exec -it immich-server_1 sh
/usr/src/app$ immich reset-admin-password
? Please choose a new password (optional) immich-is-awesome-unlike-this-password
New password:
immich-is-awesome-unlike-this-password
```

View File

@@ -30,8 +30,8 @@ const config = {
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
"docusaurus-preset-openapi",
/** @type {import('docusaurus-preset-openapi').Options} */
({
docs: {
showLastUpdateAuthor: true,
@@ -42,6 +42,10 @@ const config = {
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/immich-app/immich/tree/main/docs/",
},
api: {
path: "../server/immich-openapi-specs.json",
routeBasePath: "/docs/api"
},
// blog: {
// showReadingTime: true,
// editUrl: "https://github.com/immich-app/immich/tree/main/docs/",
@@ -80,6 +84,11 @@ const config = {
position: "right",
label: "Documentation",
},
{
to: "/docs/api",
position: "right",
label: "API"
},
{ to: "/blog", label: "Blog", position: "right" },
{
href: "https://github.com/immich-app/immich",

2742
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,11 @@
"@docusaurus/preset-classic": "2.1.0",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-preset-openapi": "^0.6.3",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react-dom": "^17.0.2",
"url": "^0.11.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.1.0",

View File

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

View File

@@ -38,6 +38,9 @@
<uses-permission android:name="android.permission.READ_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.READ_MEDIA_IMAGES" /> <!-- If you want to read images-->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- If you want to read videos-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <!-- If you want to read audio-->
<queries>
<intent>

View File

@@ -54,7 +54,9 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
val args = call.arguments<ArrayList<*>>()!!
val requireUnmeteredNetwork = args.get(0) as Boolean
val requireCharging = args.get(1) as Boolean
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
val triggerUpdateDelay = (args.get(2) as Number).toLong()
val triggerMaxDelay = (args.get(3) as Number).toLong()
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay)
result.success(true)
}
"disable" -> {

View File

@@ -37,6 +37,8 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay"
const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
@@ -62,12 +64,16 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
*/
fun configureWork(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
requireCharging: Boolean = false,
triggerUpdateDelay: Long = 5000,
triggerMaxDelay: Long = 50000) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay)
.putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay)
.apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
}
@@ -106,12 +112,14 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
}
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS)
.setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS)
.setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS)
.build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)

View File

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

View File

@@ -0,0 +1,2 @@
* Show human readable file size in detail view
* Fix permission issue on Android 33

View File

@@ -0,0 +1,6 @@
* Use binary prefixes for data sizes
* Fix not able to show device asset on Android 13
* Use cached asset info if unchanged instead of downloading all assets
* Add in-app logging
* Add search mechanism to album selection page
* Improve UI

View File

@@ -0,0 +1 @@
* Fixed foreground backup not triggered on app relaunch

View File

@@ -0,0 +1,2 @@
* Improve data usage on loading asset
* Add background backup delay

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000345">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000201">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="123.14891">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.132489">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.270764">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="38.15883">
</testcase>

View File

@@ -17,7 +17,7 @@
"backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
"backup_album_selection_page_select_albums": "Select Albums",
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_all": "All",
@@ -41,6 +41,7 @@
"backup_controller_page_background_turn_off": "Turn off background service",
"backup_controller_page_background_turn_on": "Turn on background service",
"backup_controller_page_background_wifi": "Only on WiFi",
"backup_controller_page_background_delay": "Delay new assets backup: {}",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selected: ",
"backup_controller_page_backup_sub": "Backed up photos and videos",
@@ -120,6 +121,7 @@
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_settings": "Settings",
"profile_drawer_sign_out": "Sign Out",
"profile_drawer_app_logs": "Logs",
"search_bar_hint": "Search your photos",
"search_page_no_objects": "No Objects Info Available",
"search_page_no_places": "No Places Info Available",
@@ -133,6 +135,7 @@
"setting_notifications_notify_hours": "{} hours",
"setting_notifications_notify_immediately": "immediately",
"setting_notifications_notify_minutes": "{} minutes",
"setting_notifications_notify_seconds": "{} seconds",
"setting_notifications_notify_never": "never",
"setting_notifications_subtitle": "Adjust your notification preferences",
"setting_notifications_title": "Notifications",
@@ -140,6 +143,11 @@
"setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
"setting_notifications_single_progress_title": "Show background backup detail progress",
"setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
"setting_image_viewer_preview_title": "Load preview image",
"setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.",
"setting_image_viewer_original_title": "Load original image",
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
"setting_pages_app_bar_settings": "Settings",
"share_add": "Add",
"share_add_photos": "Add photos",
@@ -164,8 +172,6 @@
"theme_setting_system_theme_switch": "Automatic (Follow system setting)",
"theme_setting_theme_subtitle": "Choose the app's theme setting",
"theme_setting_theme_title": "Theme",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
"version_announcement_overlay_ack": "Acknowledge",
"version_announcement_overlay_release_notes": "release notes",
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",

Binary file not shown.

View File

@@ -31,4 +31,4 @@ Runner/GeneratedPluginRegistrant.*
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
!default.perspectivev3

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 72;
CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 72;
CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 72;
CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.36.1</string>
<string>1.37.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>72</string>
<string>74</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

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

View File

@@ -5,32 +5,29 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000358">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000334">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.721922">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="1.671363">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.015111">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="7.167423">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.656945">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.654653">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="75.686541">
<testcase classname="fastlane.lanes" name="4: build_app" time="29.319346">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.644406">
<failure message="/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:27:in `block (2 levels) in parsing_binding&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/gems/fastlane-2.210.1/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/bin/fastlane:25:in `load&apos;&#10;/usr/local/Cellar/fastlane/2.210.1/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Error building the application - see the log above" />
</testcase>

View File

@@ -4,6 +4,7 @@ const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
@@ -25,7 +26,11 @@ const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
const String backupTriggerDelay = "immichBackupTriggerDelay"; // Key 4
// Duplicate asset
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
// In app logger
const String immichLoggerBox = "immichInAppLogger"; // Box

View File

@@ -16,11 +16,13 @@ import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.d
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
@@ -31,8 +33,10 @@ void main() async {
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
Hive.registerAdapter(ImmichLoggerMessageAdapter());
await Future.wait([
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(hiveGithubReleaseInfoBox),
@@ -58,6 +62,9 @@ void main() async {
}
}
// Initialize Immich Logger Service
ImmichLogger().init();
runApp(
EasyLocalization(
supportedLocales: locales,

View File

@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -21,8 +19,38 @@ class AlbumThumbnailCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var cardSize = MediaQuery.of(context).size.width / 2 - 18;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
final cardSize = MediaQuery.of(context).size.width / 2 - 18;
buildEmptyThumbnail() {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: SizedBox(
height: cardSize,
width: cardSize,
child: const Center(
child: Icon(Icons.no_photography),
),
),
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
);
}
return GestureDetector(
onTap: () {
@@ -35,19 +63,9 @@ class AlbumThumbnailCard extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl:
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
cacheKey: "${album.albumThumbnailAssetId}",
),
child: album.albumThumbnailAssetId == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),

View File

@@ -42,10 +42,9 @@ class SharingPage extends HookConsumerWidget {
child: CachedNetworkImage(
width: 60,
height: 60,
memCacheHeight: 200,
fit: BoxFit.cover,
imageUrl: getAlbumThumbnailUrl(album),
cacheKey: album.albumThumbnailAssetId,
cacheKey: getAlbumThumbNailCacheKey(album),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class ExifBottomSheet extends ConsumerWidget {
final Asset assetDetail;
@@ -162,7 +163,7 @@ class ExifBottomSheet extends ConsumerWidget {
),
subtitle: exifInfo.exifImageHeight != null
? Text(
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${exifInfo.fileSizeInByte!}B ",
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte!)} ",
)
: null,
),
@@ -178,7 +179,7 @@ class ExifBottomSheet extends ConsumerWidget {
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength}mm ISO${exifInfo.iso} ",
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
],

View File

@@ -20,10 +20,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override
Widget build(BuildContext context) {
bool allowMoving = _status == _RemoteImageStatus.full;
final bool forbidZoom = _status == _RemoteImageStatus.thumbnail;
return IgnorePointer(
ignoring: !allowMoving,
ignoring: forbidZoom,
child: Listener(
onPointerMove: handleSwipUpDown,
child: PhotoView(
@@ -115,7 +115,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
_thumbnailProvider = _authorizedImageProvider(
getThumbnailUrl(widget.asset.remote!),
widget.asset.id,
getThumbnailCacheKey(widget.asset.remote!),
);
_imageProvider = _thumbnailProvider;
@@ -128,10 +128,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}),
);
if (widget.threeStageLoading) {
if (widget.loadPreview) {
_previewProvider = _authorizedImageProvider(
getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
"${widget.asset.id}_previewStage",
getThumbnailCacheKey(widget.asset.remote!, type: ThumbnailFormat.JPEG),
);
_previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
@@ -140,15 +140,17 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
}
_fullProvider = _authorizedImageProvider(
getImageUrl(widget.asset.remote!),
"${widget.asset.id}_fullStage",
);
_fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.full, _fullProvider);
}),
);
if (widget.loadOriginal) {
_fullProvider = _authorizedImageProvider(
getImageUrl(widget.asset.remote!),
getImageCacheKey(widget.asset.remote!),
);
_fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.full, _fullProvider);
}),
);
}
}
@override
@@ -178,7 +180,8 @@ class RemotePhotoView extends StatefulWidget {
Key? key,
required this.asset,
required this.authToken,
required this.threeStageLoading,
required this.loadPreview,
required this.loadOriginal,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onSwipeDown,
@@ -187,7 +190,8 @@ class RemotePhotoView extends StatefulWidget {
final Asset asset;
final String authToken;
final bool threeStageLoading;
final bool loadPreview;
final bool loadOriginal;
final void Function() onSwipeDown;
final void Function() onSwipeUp;
final void Function() isZoomedFunction;

View File

@@ -31,8 +31,9 @@ class GalleryViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final Box<dynamic> box = Hive.box(userInfoBox);
final appSettingService = ref.watch(appSettingsServiceProvider);
final threeStageLoading = useState(false);
final settings = ref.watch(appSettingsServiceProvider);
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final isZoomed = useState<bool>(false);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false);
@@ -43,8 +44,10 @@ class GalleryViewerPage extends HookConsumerWidget {
useEffect(
() {
threeStageLoading.value = appSettingService
.getSetting<bool>(AppSettingsEnum.threeStageLoading);
isLoadPreview.value =
settings.getSetting<bool>(AppSettingsEnum.loadPreview);
isLoadOriginal.value =
settings.getSetting<bool>(AppSettingsEnum.loadOriginal);
isPlayingMotionVideo.value = false;
return null;
},
@@ -140,7 +143,8 @@ class GalleryViewerPage extends HookConsumerWidget {
isZoomedListener: isZoomedListener,
asset: assetList[index],
heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value,
loadPreview: isLoadPreview.value,
loadOriginal: isLoadOriginal.value,
);
}
} else {

View File

@@ -17,7 +17,8 @@ class ImageViewerPage extends HookConsumerWidget {
final String authToken;
final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction;
final bool threeStageLoading;
final bool loadPreview;
final bool loadOriginal;
ImageViewerPage({
Key? key,
@@ -26,7 +27,8 @@ class ImageViewerPage extends HookConsumerWidget {
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.threeStageLoading,
required this.loadPreview,
required this.loadOriginal,
}) : super(key: key);
Asset? assetDetail;
@@ -74,7 +76,8 @@ class ImageViewerPage extends HookConsumerWidget {
child: RemotePhotoView(
asset: asset,
authToken: authToken,
threeStageLoading: threeStageLoading,
loadPreview: loadPreview,
loadOriginal: loadOriginal,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(),

View File

@@ -86,6 +86,8 @@ class BackgroundService {
Future<bool> configureService({
bool requireUnmetered = true,
bool requireCharging = false,
int triggerUpdateDelay = 5000,
int triggerMaxDelay = 50000,
}) async {
if (!Platform.isAndroid) {
return true;
@@ -93,7 +95,12 @@ class BackgroundService {
try {
final bool ok = await _foregroundChannel.invokeMethod(
'configure',
[requireUnmetered, requireCharging],
[
requireUnmetered,
requireCharging,
triggerUpdateDelay,
triggerMaxDelay
],
);
return ok;
} catch (error) {
@@ -349,7 +356,6 @@ class BackgroundService {
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
]);
ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));

View File

@@ -18,6 +18,7 @@ class BackUpState {
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
final int backupTriggerDelay;
/// All available albums on the device
final List<AvailableAlbum> availableAlbums;
@@ -42,6 +43,7 @@ class BackUpState {
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.backupTriggerDelay,
required this.availableAlbums,
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
@@ -59,6 +61,7 @@ class BackUpState {
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
int? backupTriggerDelay,
List<AvailableAlbum>? availableAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
@@ -76,6 +79,7 @@ class BackUpState {
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay,
availableAlbums: availableAlbums ?? this.availableAlbums,
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
@@ -88,7 +92,7 @@ class BackUpState {
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -105,6 +109,7 @@ class BackUpState {
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
other.backupTriggerDelay == backupTriggerDelay &&
collectionEquals(other.availableAlbums, availableAlbums) &&
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
@@ -126,6 +131,7 @@ class BackUpState {
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
backupTriggerDelay.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -37,6 +38,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
backgroundBackup: false,
backupRequireWifi: true,
backupRequireCharging: false,
backupTriggerDelay: 5000,
serverInfo: ServerInfoResponseDto(
diskAvailable: "0",
diskAvailableRaw: 0,
@@ -62,6 +64,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
getBackupInfo();
}
final log = Logger('BackupNotifier');
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
@@ -117,18 +120,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
bool? enabled,
bool? requireWifi,
bool? requireCharging,
int? triggerDelay,
required void Function(String msg) onError,
required void Function() onBatteryInfo,
}) async {
assert(enabled != null || requireWifi != null || requireCharging != null);
assert(
enabled != null ||
requireWifi != null ||
requireCharging != null ||
triggerDelay != null,
);
if (Platform.isAndroid) {
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharing = state.backupRequireCharging;
final bool wasCharging = state.backupRequireCharging;
final int oldTriggerDelay = state.backupTriggerDelay;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
backupTriggerDelay: triggerDelay,
);
if (state.backgroundBackup) {
@@ -143,17 +154,22 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
triggerUpdateDelay: state.backupTriggerDelay,
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
await Hive.box(backgroundBackupInfoBox)
.put(backupRequireWifi, state.backupRequireWifi);
await Hive.box(backgroundBackupInfoBox)
.put(backupRequireCharging, state.backupRequireCharging);
final box = Hive.box(backgroundBackupInfoBox);
await Future.wait([
box.put(backupRequireWifi, state.backupRequireWifi),
box.put(backupRequireCharging, state.backupRequireCharging),
box.put(backupTriggerDelay, state.backupTriggerDelay),
]);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharing,
backupRequireCharging: wasCharging,
backupTriggerDelay: oldTriggerDelay,
);
onError("backup_controller_page_background_configure_error");
}
@@ -171,9 +187,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (Recent on Android, Recents on iOS)
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
///
Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
@@ -181,6 +198,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
type: RequestType.common,
);
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
@@ -218,13 +237,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (backupAlbumInfo == null) {
debugPrint("[ERROR] getting Hive backup album infomation");
log.severe(
"backupAlbumInfo == null",
"Failed to get Hive backup album information",
);
return;
}
// First time backup - set isAll album is the default one for backup.
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
debugPrint("First time backup setup recent album as default");
log.info("First time backup; setup 'Recent(s)' album as default");
// Get album that contains all assets
var list = await PhotoManager.getAssetPathList(
@@ -286,9 +308,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
} catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e");
} catch (e, stackTrace) {
log.severe("Failed to generate album from id", e, stackTrace);
}
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
///
@@ -338,7 +362,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device");
log.info("Not found albums or assets on the device to backup");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
@@ -360,14 +384,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
///
/// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded
/// and then update the UI according to those information
///
Future<void> getBackupInfo() async {
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await _updateServerInfo();
@@ -375,10 +399,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
///
/// Save user selection of selected albums and excluded albums to
/// Hive database
///
void _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Box<HiveBackupAlbums> backupAlbumInfoBox =
@@ -398,10 +420,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
///
/// Invoke backup process
///
Future<void> startBackupProcess() async {
debugPrint("Start backup process");
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
@@ -412,13 +433,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process");
log.info("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (var assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
@@ -530,7 +550,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// User has been logged out return
if (accessKey == null || !_authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
log.info("[_resumeBackup] not authenticated - abort");
return;
}
@@ -539,17 +559,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
log.info("[_resumeBackup] Backup is already in progress - abort");
return;
}
if (state.backupProgress == BackUpProgressEnum.inBackground) {
debugPrint("[resumeBackup] Background backup is running - abort");
log.info("[_resumeBackup] Background backup is running - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
log.info("[_resumeBackup] Start back up");
await startBackupProcess();
}
@@ -565,7 +585,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
final bool hasLock = await _backgroundService.acquireLock();
if (!hasLock) {
debugPrint("WARNING [resumeBackup] failed to acquireLock");
log.warning("WARNING [resumeBackup] failed to acquireLock");
return;
}
await Future.wait([
@@ -596,6 +616,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
excludedBackupAlbums: excludedAlbums,
backupRequireWifi: backgroundBox.get(backupRequireWifi),
backupRequireCharging: backgroundBox.get(backupRequireCharging),
backupTriggerDelay: backgroundBox.get(backupTriggerDelay),
);
}
return _resumeBackup();
@@ -612,7 +633,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
result.add(a.copyWith(lastBackup: times[i]));
} on StateError {
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
log.severe(
"[_updateAlbumBackupTime] failed to find album in state",
"State Error",
StackTrace.current,
);
}
}
return result;
@@ -631,21 +656,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
log.info("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
} catch (error, stackTrace) {
log.severe(
"[_notifyBackgroundServiceCanRun] failed to close box",
error,
stackTrace,
);
}
try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
} catch (error, stackTrace) {
log.severe(
"[_notifyBackgroundServiceCanRun] failed to close box",
error,
stackTrace,
);
}
_backgroundService.releaseLock();
}

View File

@@ -376,8 +376,8 @@ class BackupService {
DeviceTypeEnum deviceType,
) async {
try {
var updatedDeviceInfo = await _apiService.deviceInfoApi.updateDeviceInfo(
UpdateDeviceInfoDto(
var updatedDeviceInfo = await _apiService.deviceInfoApi.upsertDeviceInfo(
UpsertDeviceInfoDto(
deviceId: deviceId,
deviceType: deviceType,
isAutoBackup: status,

View File

@@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
title: Column(
children: [
Text(
"${album.name} (${album.assetCountAsync})",
album.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
Padding(

View File

@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final availableAlbums = ref.watch(backupProvider).availableAlbums;
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final albums = useState<List<AvailableAlbum>>(
ref.watch(backupProvider).availableAlbums,
);
useEffect(
() {
@@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
);
buildAlbumSelectionList() {
if (availableAlbums.isEmpty) {
if (albums.value.isEmpty) {
return const Center(
child: ImmichLoadingIndicator(),
);
@@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
height: 265,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: availableAlbums.length,
itemCount: albums.value.length,
physics: const BouncingScrollPhysics(),
itemBuilder: ((context, index) {
var thumbnailData = availableAlbums[index].thumbnailData;
var thumbnailData = albums.value[index].thumbnailData;
return Padding(
padding: index == 0
? const EdgeInsets.only(left: 16.00)
: const EdgeInsets.all(0),
child: AlbumInfoCard(
imageData: thumbnailData,
albumInfo: availableAlbums[index],
albumInfo: albums.value[index],
),
);
}),
@@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
),
label: Text(
album.name,
style: TextStyle(
fontSize: 10,
color: Theme.of(context).brightness == Brightness.dark
? Colors.black
: Colors.white,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
@@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
),
label: Text(
album.name,
@@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet();
}
buildSearchBar() {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: (searchValue) {
albums.value = ref
.watch(backupProvider)
.availableAlbums
.where(
(album) => album.name
.toLowerCase()
.contains(searchValue.toLowerCase()),
)
.toList();
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
hintText: "Search",
hintStyle: TextStyle(
color: isDarkTheme ? Colors.white : Colors.grey,
fontSize: 14.0,
),
prefixIcon: const Icon(
Icons.search,
color: Colors.grey,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
filled: true,
fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
),
),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
@@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0)
@@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
ListTile(
title: Text(
"backup_album_selection_page_albums_device"
.tr(args: [availableAlbums.length.toString()]),
"backup_album_selection_page_albums_device".tr(
args: [
ref.watch(backupProvider).availableAlbums.length.toString()
],
),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
@@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
title: Text(
@@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
),
buildSearchBar(),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: buildAlbumSelectionList(),

View File

@@ -198,6 +198,46 @@ class BackupControllerPage extends HookConsumerWidget {
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final Color activeColor = Theme.of(context).primaryColor;
String formatBackupDelaySliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['5']);
} else if (v == 1.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['2']);
} else {
return 'setting_notifications_notify_minutes'.tr(args: const ['10']);
}
}
int backupDelayToMilliseconds(double v) {
if (v == 0.0) {
return 5000;
} else if (v == 1.0) {
return 30000;
} else if (v == 2.0) {
return 120000;
} else {
return 600000;
}
}
double backupDelayToSliderValue(int ms) {
if (ms == 5000) {
return 0.0;
} else if (ms == 30000) {
return 1.0;
} else if (ms == 120000) {
return 2.0;
} else {
return 3.0;
}
}
final triggerDelay =
useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
return ListTile(
isThreeLine: true,
leading: isBackgroundEnabled
@@ -264,6 +304,35 @@ class BackupControllerPage extends HookConsumerWidget {
)
: null,
),
if (isBackgroundEnabled)
ListTile(
isThreeLine: false,
dense: true,
enabled: hasExclusiveAccess,
title: const Text(
'backup_controller_page_background_delay',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(args: [formatBackupDelaySliderValue(triggerDelay.value)]),
subtitle: Slider(
value: triggerDelay.value,
onChanged: hasExclusiveAccess
? (double v) => triggerDelay.value = v
: null,
onChangeEnd: (double v) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(v),
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
max: 3.0,
divisions: 3,
label: formatBackupDelaySliderValue(triggerDelay.value),
activeColor: Theme.of(context).primaryColor,
),
),
ElevatedButton(
onPressed: () =>
ref.read(backupProvider.notifier).configureBackgroundBackup(

View File

@@ -10,8 +10,10 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(
@@ -25,42 +27,31 @@ class AssetService {
final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
final log = Logger('AssetService');
AssetService(this._apiService, this._backupService, this._backgroundService);
/// Returns all local, remote assets in that order
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
final List<Asset> assets = [];
/// Returns `null` if the server state did not change, else list of assets
Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async {
try {
// not using `await` here to fetch local & remote assets concurrently
final Future<List<AssetResponseDto>?> remoteTask =
_apiService.assetApi.getAllAssets();
final Iterable<AssetEntity> newLocalAssets;
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remoteAssets
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
} else {
newLocalAssets = localAssets;
final Pair<List<AssetResponseDto>, String?>? remote =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
if (remote == null) {
return const Pair(null, null);
}
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
// the order (first all local, then remote assets) is important!
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
} catch (e) {
debugPrint("Error [getAllAsset] ${e.toString()}");
return Pair(
remote.first.map(Asset.remote).toList(growable: false),
remote.second,
);
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
return const Pair(null, null);
}
return assets;
}
/// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns an empty list instead after a timeout.
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
/// to finish running. Returns `null` instead after a timeout.
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
try {
final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess
@@ -71,15 +62,16 @@ class AssetService {
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
return backupAlbumInfo != null
? await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())
: [];
if (backupAlbumInfo != null) {
return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map(Asset.local)
.toList(growable: false);
}
} catch (e) {
debugPrint("Error [_getLocalAssets] ${e.toString()}");
return [];
}
return null;
}
Future<Asset?> getAssetById(String assetId) async {

View File

@@ -75,14 +75,11 @@ class ControlBottomAppBar extends ConsumerWidget {
width: 100,
height: 100,
fit: BoxFit.cover,
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
imageUrl: getAlbumThumbnailUrl(album),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
cacheKey: "${album.albumThumbnailAssetId}",
cacheKey: getAlbumThumbNailCacheKey(album),
),
),
Padding(

View File

@@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
snap: false,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
leading: Builder(
builder: (BuildContext context) {

View File

@@ -2,12 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends HookConsumerWidget {
@@ -70,6 +70,30 @@ class ProfileDrawer extends HookConsumerWidget {
);
}
buildAppLogButton() {
return ListTile(
horizontalTitleGap: 0,
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.assignment_outlined,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_app_logs",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const AppLogRoute());
},
);
}
return Drawer(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -80,6 +104,7 @@ class ProfileDrawer extends HookConsumerWidget {
children: [
const ProfileDrawerHeader(),
buildSettingButton(),
buildAppLogButton(),
buildSignoutButton(),
],
),

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -20,6 +22,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
@@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget {
final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0);
useEffect(
() {
ref.read(websocketProvider.notifier).connect();
@@ -146,6 +151,49 @@ class HomePage extends HookConsumerWidget {
}
}
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () {
tipOneOpacity.value = 1;
});
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'Building the timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: const SizedBox(
width: 250,
child: Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
),
),
),
)
],
),
);
}
return SafeArea(
bottom: !multiselectEnabled.state,
top: true,
@@ -164,15 +212,17 @@ class HomePage extends HookConsumerWidget {
top: selectionEnabledHook.value ? 0 : 60,
bottom: 0.0,
),
child: ImmichAssetGrid(
renderList: renderList,
assetsPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
child: ref.watch(assetProvider).isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: renderList,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
),
if (selectionEnabledHook.value)
ControlBottomAppBar(

View File

@@ -101,11 +101,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
Future<bool> logout() async {
Hive.box(userInfoBox).delete(accessTokenKey);
state = state.copyWith(isAuthenticated: false);
_assetCacheService.invalidate();
_albumCacheService.invalidate();
_sharedAlbumCacheService.invalidate();
await Future.wait([
Hive.box(userInfoBox).delete(accessTokenKey),
Hive.box(userInfoBox).delete(assetEtagKey),
_assetCacheService.invalidate(),
_albumCacheService.invalidate(),
_sharedAlbumCacheService.invalidate(),
]);
// Remove login info from local storage
var loginInfo =
@@ -115,7 +118,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
loginInfo.password = "";
loginInfo.isSaveLogin = false;
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
await Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
loginInfo,
);
@@ -207,8 +210,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
// Register device info
try {
DeviceInfoResponseDto? deviceInfo =
await _apiService.deviceInfoApi.createDeviceInfo(
CreateDeviceInfoDto(
await _apiService.deviceInfoApi.upsertDeviceInfo(
UpsertDeviceInfoDto(
deviceId: state.deviceId,
deviceType: state.deviceType,
),

View File

@@ -83,6 +83,13 @@ class LoginForm extends HookConsumerWidget {
[],
);
populateTestLoginInfo() {
usernameController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
isSaveLoginInfo.value = true;
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
@@ -92,10 +99,13 @@ class LoginForm extends HookConsumerWidget {
runSpacing: 16,
alignment: WrapAlignment.center,
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 100,
filterQuality: FilterQuality.high,
GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
child: const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 100,
filterQuality: FilterQuality.high,
),
),
Text(
'IMMICH',

View File

@@ -1,14 +1,65 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/ui/login_form.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:package_info_plus/package_info_plus.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold(
body: LoginForm(),
final appVersion = useState('0.0.0');
getAppInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appVersion.value = packageInfo.version;
}
useEffect(
() {
getAppInfo();
return null;
},
);
return Scaffold(
body: const LoginForm(),
bottomNavigationBar: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: SizedBox(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'v${appVersion.value}',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
),
const Text(' '),
GestureDetector(
child: Text(
'Logs',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
),
onTap: () {
AutoRouter.of(context).push(const AppLogRoute());
},
),
],
),
),
),
);
}
}

View File

@@ -2,7 +2,8 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
enum AppSettingsEnum<T> {
threeStageLoading<bool>("threeStageLoading", false),
loadPreview<bool>("loadPreview", true),
loadOriginal<bool>("loadOriginal", false),
themeMode<String>("themeMode", "system"), // "light","dark","system"
tilesPerRow<int>("tilesPerRow", 4),
uploadErrorNotificationGracePeriod<int>(

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
SwitchListTile buildSwitchListTile(
BuildContext context,
AppSettingsService appSettingService,
ValueNotifier<bool> valueNotifier,
AppSettingsEnum settingsEnum, {
required String title,
String? subtitle,
}) {
return SwitchListTile.adaptive(
key: Key(settingsEnum.name),
value: valueNotifier.value,
onChanged: (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme.of(context).primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: subtitle != null ? Text(subtitle) : null,
);
}

View File

@@ -1,14 +1,30 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/three_stage_loading.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/common.dart';
class ImageViewerQualitySetting extends StatelessWidget {
class ImageViewerQualitySetting extends HookConsumerWidget {
const ImageViewerQualitySetting({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
final isPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
useEffect(
() {
isPreview.value = settings.getSetting(AppSettingsEnum.loadPreview);
isOriginal.value = settings.getSetting(AppSettingsEnum.loadOriginal);
return null;
},
);
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
@@ -23,8 +39,27 @@ class ImageViewerQualitySetting extends StatelessWidget {
fontSize: 13,
),
).tr(),
children: const [
ThreeStageLoading(),
children: [
ListTile(
title: const Text('setting_image_viewer_help').tr(),
dense: true,
),
buildSwitchListTile(
context,
settings,
isPreview,
AppSettingsEnum.loadPreview,
title: "setting_image_viewer_preview_title".tr(),
subtitle: "setting_image_viewer_preview_subtitle".tr(),
),
buildSwitchListTile(
context,
settings,
isOriginal,
AppSettingsEnum.loadOriginal,
title: "setting_image_viewer_original_title".tr(),
subtitle: "setting_image_viewer_original_subtitle".tr(),
),
],
);
}

View File

@@ -1,57 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class ThreeStageLoading extends HookConsumerWidget {
const ThreeStageLoading({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final isEnable = useState(false);
useEffect(
() {
var isThreeStageLoadingEnable =
appSettingService.getSetting(AppSettingsEnum.threeStageLoading);
isEnable.value = isThreeStageLoadingEnable;
return null;
},
[],
);
void onSwitchChanged(bool switchValue) {
appSettingService.setSetting(
AppSettingsEnum.threeStageLoading,
switchValue,
);
isEnable.value = switchValue;
}
return SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
"theme_setting_three_stage_loading_title",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
"theme_setting_three_stage_loading_subtitle",
style: TextStyle(
fontSize: 12,
),
).tr(),
value: isEnable.value,
onChanged: onSwitchChanged,
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/common.dart';
class NotificationSetting extends HookConsumerWidget {
const NotificationSetting({
@@ -50,7 +51,7 @@ class NotificationSetting extends HookConsumerWidget {
),
).tr(),
children: [
_buildSwitchListTile(
buildSwitchListTile(
context,
appSettingService,
totalProgressValue,
@@ -58,7 +59,7 @@ class NotificationSetting extends HookConsumerWidget {
title: 'setting_notifications_total_progress_title'.tr(),
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
),
_buildSwitchListTile(
buildSwitchListTile(
context,
appSettingService,
singleProgressValue,
@@ -91,28 +92,6 @@ class NotificationSetting extends HookConsumerWidget {
}
}
SwitchListTile _buildSwitchListTile(
BuildContext context,
AppSettingsService appSettingService,
ValueNotifier<bool> valueNotifier,
AppSettingsEnum settingsEnum, {
required String title,
String? subtitle,
}) {
return SwitchListTile(
key: Key(settingsEnum.name),
value: valueNotifier.value,
onChanged: (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme.of(context).primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: subtitle != null ? Text(subtitle) : null,
);
}
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();

View File

@@ -1,33 +1,34 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.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/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/views/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -80,6 +81,10 @@ part 'router.gr.dart';
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
CustomRoute(
page: AppLogPage,
transitionsBuilder: TransitionsBuilders.slideBottom,
),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -59,7 +59,8 @@ class _$AppRouter extends RootStackRouter {
authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener,
threeStageLoading: args.threeStageLoading));
loadPreview: args.loadPreview,
loadOriginal: args.loadOriginal));
},
VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -142,6 +143,14 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
routeData: routeData, child: const SettingsPage());
},
AppLogRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const AppLogPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
@@ -218,7 +227,8 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(FailedBackupStatusRoute.name,
path: '/failed-backup-status-page', guards: [authGuard]),
RouteConfig(SettingsRoute.name,
path: '/settings-page', guards: [authGuard])
path: '/settings-page', guards: [authGuard]),
RouteConfig(AppLogRoute.name, path: '/app-log-page')
];
}
@@ -296,7 +306,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
required String authToken,
required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener,
required bool threeStageLoading})
required bool loadPreview,
required bool loadOriginal})
: super(ImageViewerRoute.name,
path: '/image-viewer-page',
args: ImageViewerRouteArgs(
@@ -306,7 +317,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
threeStageLoading: threeStageLoading));
loadPreview: loadPreview,
loadOriginal: loadOriginal));
static const String name = 'ImageViewerRoute';
}
@@ -319,7 +331,8 @@ class ImageViewerRouteArgs {
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.threeStageLoading});
required this.loadPreview,
required this.loadOriginal});
final Key? key;
@@ -333,11 +346,13 @@ class ImageViewerRouteArgs {
final ValueNotifier<bool> isZoomedListener;
final bool threeStageLoading;
final bool loadPreview;
final bool loadOriginal;
@override
String toString() {
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, threeStageLoading: $threeStageLoading}';
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal}';
}
}
@@ -560,6 +575,14 @@ class SettingsRoute extends PageRouteInfo<void> {
static const String name = 'SettingsRoute';
}
/// generated route for
/// [AppLogPage]
class AppLogRoute extends PageRouteInfo<void> {
const AppLogRoute() : super(AppLogRoute.name, path: '/app-log-page');
static const String name = 'AppLogRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@@ -0,0 +1,34 @@
import 'package:hive/hive.dart';
part 'immich_logger_message.model.g.dart';
@HiveType(typeId: 3)
class ImmichLoggerMessage {
@HiveField(0)
String message;
@HiveField(1, defaultValue: "INFO")
String level;
@HiveField(2)
DateTime createdAt;
@HiveField(3)
String? context1;
@HiveField(4)
String? context2;
ImmichLoggerMessage({
required this.message,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'immich_logger_message.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
@override
final int typeId = 3;
@override
ImmichLoggerMessage read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ImmichLoggerMessage(
message: fields[0] as String,
level: fields[1] == null ? 'INFO' : fields[1] as String,
createdAt: fields[2] as DateTime,
context1: fields[3] as String?,
context2: fields[4] as String?,
);
}
@override
void write(BinaryWriter writer, ImmichLoggerMessage obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.message)
..writeByte(1)
..write(obj.level)
..writeByte(2)
..write(obj.createdAt)
..writeByte(3)
..write(obj.context1)
..writeByte(4)
..write(obj.context2);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ImmichLoggerMessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,20 +1,23 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<Asset>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
@@ -33,32 +36,65 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final stopwatch = Stopwatch();
try {
_getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start();
final Box box = Hive.box(userInfoBox);
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets(
etag: isCacheValid ? box.get(assetEtagKey) : null,
);
if (isCacheValid && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint(
log.info(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
stopwatch.start();
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
int remoteBegin = state.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
final List<Asset> currentLocal = state.slice(0, remoteBegin);
final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
List<Asset>? newRemote = remoteResult.first;
List<Asset>? newLocal = await localTask;
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (newRemote == null &&
(newLocal == null || currentLocal.equals(newLocal))) {
log.info("state is already up-to-date");
return;
}
newRemote ??= state.slice(remoteBegin);
newLocal ??= [];
state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
state = allAssets;
stopwatch.reset();
_cacheState();
box.put(assetEtagKey, remoteResult.second);
log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getAllAssetInProgress = false;
}
debugPrint("[getAllAsset] setting new asset state");
}
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
List<Asset> _combineLocalAndRemoteAssets({
required Iterable<Asset> local,
required List<Asset> remote,
}) {
final List<Asset> assets = [];
if (remote.isNotEmpty && local.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remote
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
local = local.where((e) => !existingIds.contains(e.id));
}
assets.addAll(local);
// the order (first all local, then remote assets) is important!
assets.addAll(remote);
return assets;
}
clearAllAsset() {
@@ -124,8 +160,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
}
return [];

View File

@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:logging/logging.dart';
class ReleaseInfoNotifier extends StateNotifier<String> {
ReleaseInfoNotifier() : super("");
final log = Logger('ReleaseInfoNotifier');
void checkGithubReleaseInfo() async {
final Client client = Client();
var box = Hive.box(hiveGithubReleaseInfoBox);
@@ -28,9 +29,6 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
String latestTagVersion = data["tag_name"];
state = latestTagVersion;
debugPrint("Local release version $localReleaseVersion");
debugPrint("Remote release veresion $latestTagVersion");
if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
VersionAnnouncementOverlayController.appLoader.show();
return;

View File

@@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.dart';
class WebscoketState {
class WebsocketState {
final Socket? socket;
final bool isConnected;
WebscoketState({
WebsocketState({
this.socket,
required this.isConnected,
});
WebscoketState copyWith({
WebsocketState copyWith({
Socket? socket,
bool? isConnected,
}) {
return WebscoketState(
return WebsocketState(
socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected,
);
@@ -30,13 +31,13 @@ class WebscoketState {
@override
String toString() =>
'WebscoketState(socket: $socket, isConnected: $isConnected)';
'WebsocketState(socket: $socket, isConnected: $isConnected)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is WebscoketState &&
return other is WebsocketState &&
other.socket == socket &&
other.isConnected == isConnected;
}
@@ -45,12 +46,11 @@ class WebscoketState {
int get hashCode => socket.hashCode ^ isConnected.hashCode;
}
class WebsocketNotifier extends StateNotifier<WebscoketState> {
class WebsocketNotifier extends StateNotifier<WebsocketState> {
WebsocketNotifier(this.ref)
: super(WebscoketState(socket: null, isConnected: false)) {
debugPrint("Init websocket instance");
}
: super(WebsocketState(socket: null, isConnected: false));
final log = Logger('WebsocketNotifier');
final Ref ref;
connect() {
@@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
try {
debugPrint("[WEBSOCKET] Attempting to connect to ws");
// Configure socket transports must be sepecified
debugPrint("Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(
endpoint.toString().replaceAll('/api', ''),
OptionBuilder()
@@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
);
socket.onConnect((_) {
debugPrint("[WEBSOCKET] Established Websocket Connection");
state = WebscoketState(isConnected: true, socket: socket);
debugPrint("Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket);
});
socket.onDisconnect((_) {
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
state = WebscoketState(isConnected: false, socket: null);
debugPrint("Disconnect to Websocket Connection");
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('error', (errorMessage) {
debugPrint("Webcoket Error - $errorMessage");
state = WebscoketState(isConnected: false, socket: null);
log.severe("Websocket Error - $errorMessage");
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('on_upload_success', (data) {
@@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
}
disconnect() {
debugPrint("[WEBSOCKET] Attempting to disconnect");
debugPrint("Attempting to disconnect from websocket");
var socket = state.socket?.disconnect();
if (socket?.disconnected == true) {
state = WebscoketState(isConnected: false, socket: null);
state = WebsocketState(isConnected: false, socket: null);
}
}
stopListenToEvent(String eventName) {
debugPrint("[Websocket] Stop listening to event $eventName");
debugPrint("Stop listening to event $eventName");
state.socket?.off(eventName);
}
listenUploadEvent() {
debugPrint("[Websocket] Start listening to event on_upload_success");
debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
@@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
}
final websocketProvider =
StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
return WebsocketNotifier(ref);
});

View File

@@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to a Hive box and onto console, using `debugPrint` method.
///
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file.
class ImmichLogger {
final maxLogEntries = 200;
final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
List<ImmichLoggerMessage> get messages =>
_box.values.toList().reversed.toList();
ImmichLogger() {
_removeOverflowMessages();
}
init() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen(_writeLogToHiveBox);
}
_removeOverflowMessages() {
if (_box.length > maxLogEntries) {
var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
_box.deleteAt(0);
}
}
}
_writeLogToHiveBox(LogRecord record) {
final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
var formattedMessage = record.message;
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
box.add(
ImmichLoggerMessage(
message: formattedMessage,
level: record.level.name,
createdAt: record.time,
context1: record.loggerName,
context2: record.stackTrace?.toString(),
),
);
}
void clearLogs() {
_box.clear();
}
Future<void> shareLogs() async {
final tempDir = await getTemporaryDirectory();
final dateTime = DateTime.now().toIso8601String();
final filePath = '${tempDir.path}/Immich_log_$dateTime.csv';
final logFile = await File(filePath).create();
final io = logFile.openWrite();
try {
// Write header
io.write("created_at,level,context,message,stacktrace\n");
// Write messages
for (final m in messages) {
io.write(
'${m.createdAt},${m.level},"${m.context1 ?? ""}","${m.message}","${m.context2 ?? ""}"\n',
);
}
} finally {
await io.flush();
await io.close();
}
// Share file
await Share.shareFiles(
[filePath],
subject: "Immich logs $dateTime",
sharePositionOrigin: Rect.zero,
);
// Clean up temp file
await logFile.delete();
}
}

View File

@@ -23,8 +23,12 @@ abstract class JsonCache<T> {
}
Future<void> invalidate() async {
final file = await _getCacheFile();
await file.delete();
try {
final file = await _getCacheFile();
await file.delete();
} on FileSystemException {
// file is already deleted
}
}
Future<void> putRawData(dynamic data) async {
@@ -46,4 +50,4 @@ abstract class JsonCache<T> {
void put(T data);
Future<T> get();
}
}

View File

@@ -62,7 +62,7 @@ class ImmichImage extends StatelessWidget {
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"},
cacheKey: 'thumbnail-image-${asset.id}',
cacheKey: getThumbnailCacheKey(asset.remote!),
width: width,
height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and

View File

@@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.all(15),
child: const CircularProgressIndicator(color: Colors.white),
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:intl/intl.dart';
class AppLogPage extends HookConsumerWidget {
const AppLogPage({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final immichLogger = ImmichLogger();
final logMessages = useState(immichLogger.messages);
Widget colorStatusIndicator(Color color) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
);
}
Widget buildLeadingIcon(String level) {
switch (level) {
case "INFO":
return colorStatusIndicator(Theme.of(context).primaryColor);
case "SEVERE":
return colorStatusIndicator(Colors.redAccent);
case "WARNING":
return colorStatusIndicator(Colors.orangeAccent);
default:
return colorStatusIndicator(Colors.grey);
}
}
getTileColor(String level) {
switch (level) {
case "INFO":
return Colors.transparent;
case "SEVERE":
return Theme.of(context).brightness == Brightness.dark
? Colors.redAccent.withOpacity(0.25)
: Colors.redAccent.withOpacity(0.075);
case "WARNING":
return Theme.of(context).brightness == Brightness.dark
? Colors.orangeAccent.withOpacity(0.25)
: Colors.orangeAccent.withOpacity(0.075);
default:
return Theme.of(context).primaryColor.withOpacity(0.1);
}
}
return Scaffold(
appBar: AppBar(
title: Text(
"Logs - ${logMessages.value.length}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
scrolledUnderElevation: 1,
elevation: 2,
actions: [
IconButton(
icon: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).primaryColor,
semanticLabel: "Clear logs",
size: 20.0,
),
onPressed: () {
immichLogger.clearLogs();
logMessages.value = [];
},
),
IconButton(
icon: Icon(
Icons.share_rounded,
color: Theme.of(context).primaryColor,
semanticLabel: "Share logs",
size: 20.0,
),
onPressed: () {
immichLogger.shareLogs();
},
),
],
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
centerTitle: true,
),
body: ListView.separated(
separatorBuilder: (context, index) {
return Divider(
height: 0,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[600],
);
},
itemCount: logMessages.value.length,
itemBuilder: (context, index) {
var logMessage = logMessages.value[index];
return ListTile(
visualDensity: VisualDensity.compact,
dense: true,
tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10,
title: Text.rich(
TextSpan(
children: [
TextSpan(
text: "#$index ",
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[600],
fontSize: 14.0,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: logMessage.message,
style: const TextStyle(
fontSize: 14.0,
),
),
],
),
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
),
subtitle: Text(
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
),
),
leading: buildLeadingIcon(logMessage.level),
);
},
),
);
}
}

View File

@@ -1,15 +1,17 @@
String formatBytes(int bytes) {
if (bytes < 1000) {
return "$bytes B";
} else if (bytes < 1000000) {
final kb = (bytes / 1000).toStringAsFixed(1);
return "$kb kB";
} else if (bytes < 1000000000) {
final mb = (bytes / 1000000).toStringAsFixed(1);
return "$mb MB";
} else {
final gb = (bytes / 1000000000).toStringAsFixed(1);
return "$gb GB";
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
int magnitude = 0;
double remainder = bytes.toDouble();
while (remainder >= 1024) {
if (magnitude + 1 < units.length) {
magnitude++;
remainder /= 1024;
}
else {
break;
}
}
}
return "${remainder.toStringAsFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}";
}

View File

@@ -10,6 +10,19 @@ String getThumbnailUrl(
return _getThumbnailUrl(asset.id, type: type);
}
String getThumbnailCacheKey(final AssetResponseDto asset,
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
return _getThumbnailCacheKey(asset.id, type);
}
String _getThumbnailCacheKey(final String id, final ThumbnailFormat type) {
if (type == ThumbnailFormat.WEBP) {
return 'thumbnail-image-$id';
} else {
return '${id}_previewStage';
}
}
String getAlbumThumbnailUrl(
final AlbumResponseDto album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
@@ -20,11 +33,25 @@ String getAlbumThumbnailUrl(
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type);
}
String getAlbumThumbNailCacheKey(
final AlbumResponseDto album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
if (album.albumThumbnailAssetId == null) {
return '';
}
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
}
String getImageUrl(final AssetResponseDto asset) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/file/${asset.id}?isThumb=false';
}
String getImageCacheKey(final AssetResponseDto asset) {
return '${asset.id}_fullStage';
}
String _getThumbnailUrl(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:openapi/api.dart';
import 'tuple.dart';
/// Extension methods to retrieve ETag together with the API call
extension WithETag on AssetApi {
/// Get all AssetEntity belong to the user
///
/// Parameters:
///
/// * [String] eTag:
/// ETag of data already cached on the client
Future<Pair<List<AssetResponseDto>, String?>?> getAllAssetsWithETag({
String? eTag,
}) async {
final response = await getAllAssetsWithHttpInfo(
ifNoneMatch: eTag,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty &&
response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
final etag = response.headers[HttpHeaders.etagHeader];
final data = (await apiClient.deserializeAsync(
responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList();
return Pair(data, etag);
}
return null;
}
}
/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json'
/// content type. Otherwise, returns the decoded body as decoded by dart:http package.
Future<String> _decodeBodyBytes(Response response) async {
final contentType = response.headers['content-type'];
return contentType != null &&
contentType.toLowerCase().startsWith('application/json')
? response.bodyBytes.isEmpty
? ''
: utf8.decode(response.bodyBytes)
: response.body;
}

View File

@@ -0,0 +1,8 @@
/// An immutable pair or 2-tuple
/// TODO replace with Record once Dart 2.19 is available
class Pair<T1, T2> {
final T1 first;
final T2 second;
const Pair(this.first, this.second);
}

View File

@@ -1,4 +1,5 @@
.gitignore
.openapi-generator-ignore
.travis.yml
README.md
analysis_options.yaml
@@ -23,8 +24,8 @@ doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md
doc/CreateAlbumDto.md
doc/CreateDeviceInfoDto.md
doc/CreateProfileImageResponseDto.md
doc/CreateTagDto.md
doc/CreateUserDto.md
doc/CuratedLocationsResponseDto.md
doc/CuratedObjectsResponseDto.md
@@ -60,15 +61,19 @@ doc/ServerVersionReponseDto.md
doc/SignUpDto.md
doc/SmartInfoResponseDto.md
doc/SystemConfigApi.md
doc/SystemConfigKey.md
doc/SystemConfigResponseDto.md
doc/SystemConfigResponseItem.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigOAuthDto.md
doc/TagApi.md
doc/TagResponseDto.md
doc/TagTypeEnum.md
doc/ThumbnailFormat.md
doc/TimeGroupEnum.md
doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md
doc/UpdateDeviceInfoDto.md
doc/UpdateTagDto.md
doc/UpdateUserDto.md
doc/UpsertDeviceInfoDto.md
doc/UsageByUserDto.md
doc/UserApi.md
doc/UserCountResponseDto.md
@@ -84,6 +89,7 @@ lib/api/job_api.dart
lib/api/o_auth_api.dart
lib/api/server_info_api.dart
lib/api/system_config_api.dart
lib/api/tag_api.dart
lib/api/user_api.dart
lib/api_client.dart
lib/api_exception.dart
@@ -111,8 +117,8 @@ lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart
lib/model/create_album_dto.dart
lib/model/create_device_info_dto.dart
lib/model/create_profile_image_response_dto.dart
lib/model/create_tag_dto.dart
lib/model/create_user_dto.dart
lib/model/curated_locations_response_dto.dart
lib/model/curated_objects_response_dto.dart
@@ -143,17 +149,96 @@ lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart
lib/model/system_config_key.dart
lib/model/system_config_response_dto.dart
lib/model/system_config_response_item.dart
lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_o_auth_dto.dart
lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart
lib/model/time_group_enum.dart
lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart
lib/model/update_device_info_dto.dart
lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart
lib/model/upsert_device_info_dto.dart
lib/model/usage_by_user_dto.dart
lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
pubspec.yaml
test/add_assets_dto_test.dart
test/add_assets_response_dto_test.dart
test/add_users_dto_test.dart
test/admin_signup_response_dto_test.dart
test/album_api_test.dart
test/album_count_response_dto_test.dart
test/album_response_dto_test.dart
test/all_job_status_response_dto_test.dart
test/asset_api_test.dart
test/asset_count_by_time_bucket_response_dto_test.dart
test/asset_count_by_time_bucket_test.dart
test/asset_count_by_user_id_response_dto_test.dart
test/asset_file_upload_response_dto_test.dart
test/asset_response_dto_test.dart
test/asset_type_enum_test.dart
test/authentication_api_test.dart
test/check_duplicate_asset_dto_test.dart
test/check_duplicate_asset_response_dto_test.dart
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart
test/create_album_dto_test.dart
test/create_profile_image_response_dto_test.dart
test/create_tag_dto_test.dart
test/create_user_dto_test.dart
test/curated_locations_response_dto_test.dart
test/curated_objects_response_dto_test.dart
test/delete_asset_dto_test.dart
test/delete_asset_response_dto_test.dart
test/delete_asset_status_test.dart
test/device_info_api_test.dart
test/device_info_response_dto_test.dart
test/device_type_enum_test.dart
test/exif_response_dto_test.dart
test/get_asset_by_time_bucket_dto_test.dart
test/get_asset_count_by_time_bucket_dto_test.dart
test/job_api_test.dart
test/job_command_dto_test.dart
test/job_command_test.dart
test/job_counts_test.dart
test/job_id_test.dart
test/job_status_response_dto_test.dart
test/login_credential_dto_test.dart
test/login_response_dto_test.dart
test/logout_response_dto_test.dart
test/o_auth_api_test.dart
test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/remove_assets_dto_test.dart
test/search_asset_dto_test.dart
test/server_info_api_test.dart
test/server_info_response_dto_test.dart
test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart
test/server_version_reponse_dto_test.dart
test/sign_up_dto_test.dart
test/smart_info_response_dto_test.dart
test/system_config_api_test.dart
test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart
test/system_config_o_auth_dto_test.dart
test/tag_api_test.dart
test/tag_response_dto_test.dart
test/tag_type_enum_test.dart
test/thumbnail_format_test.dart
test/time_group_enum_test.dart
test/update_album_dto_test.dart
test/update_asset_dto_test.dart
test/update_tag_dto_test.dart
test/update_user_dto_test.dart
test/upsert_device_info_dto_test.dart
test/usage_by_user_dto_test.dart
test/user_api_test.dart
test/user_count_response_dto_test.dart
test/user_response_dto_test.dart
test/validate_access_token_response_dto_test.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.17.0
- API version: 1.38.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -93,7 +93,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |
*AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search |
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{assetId} |
*AssetApi* | [**updateAssetById**](doc//AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} |
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{assetId} |
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
@@ -101,6 +101,7 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
*DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
*DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**getJobStatus**](doc//JobApi.md#getjobstatus) | **GET** /jobs/{jobId} |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
@@ -111,7 +112,13 @@ Class | Method | HTTP request | Description
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config |
*SystemConfigApi* | [**getDefaults**](doc//SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults |
*SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config |
*TagApi* | [**create**](doc//TagApi.md#create) | **POST** /tag |
*TagApi* | [**delete**](doc//TagApi.md#delete) | **DELETE** /tag/{id} |
*TagApi* | [**findAll**](doc//TagApi.md#findall) | **GET** /tag |
*TagApi* | [**findOne**](doc//TagApi.md#findone) | **GET** /tag/{id} |
*TagApi* | [**update**](doc//TagApi.md#update) | **PATCH** /tag/{id} |
*UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image |
*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user |
*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{userId} |
@@ -144,8 +151,8 @@ Class | Method | HTTP request | Description
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateDeviceInfoDto](doc//CreateDeviceInfoDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- [CreateTagDto](doc//CreateTagDto.md)
- [CreateUserDto](doc//CreateUserDto.md)
- [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md)
- [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md)
@@ -176,15 +183,18 @@ Class | Method | HTTP request | Description
- [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
- [SignUpDto](doc//SignUpDto.md)
- [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
- [SystemConfigKey](doc//SystemConfigKey.md)
- [SystemConfigResponseDto](doc//SystemConfigResponseDto.md)
- [SystemConfigResponseItem](doc//SystemConfigResponseItem.md)
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [TagResponseDto](doc//TagResponseDto.md)
- [TagTypeEnum](doc//TagTypeEnum.md)
- [ThumbnailFormat](doc//ThumbnailFormat.md)
- [TimeGroupEnum](doc//TimeGroupEnum.md)
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
- [UpdateTagDto](doc//UpdateTagDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UpsertDeviceInfoDto](doc//UpsertDeviceInfoDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserCountResponseDto](doc//UserCountResponseDto.md)
- [UserResponseDto](doc//UserResponseDto.md)

View File

@@ -26,7 +26,7 @@ Method | HTTP request | Description
[**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |
[**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search |
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{assetId} |
[**updateAssetById**](AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} |
[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{assetId} |
[**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload |
@@ -274,7 +274,7 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllAssets**
> List<AssetResponseDto> getAllAssets()
> List<AssetResponseDto> getAllAssets(ifNoneMatch)
@@ -291,9 +291,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
try {
final result = api_instance.getAllAssets();
final result = api_instance.getAllAssets(ifNoneMatch);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAllAssets: $e\n');
@@ -301,7 +302,10 @@ try {
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
### Return type
@@ -829,8 +833,8 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateAssetById**
> AssetResponseDto updateAssetById(assetId, updateAssetDto)
# **updateAsset**
> AssetResponseDto updateAsset(assetId, updateAssetDto)
@@ -851,10 +855,10 @@ final assetId = assetId_example; // String |
final updateAssetDto = UpdateAssetDto(); // UpdateAssetDto |
try {
final result = api_instance.updateAssetById(assetId, updateAssetDto);
final result = api_instance.updateAsset(assetId, updateAssetDto);
print(result);
} catch (e) {
print('Exception when calling AssetApi->updateAssetById: $e\n');
print('Exception when calling AssetApi->updateAsset: $e\n');
}
```

View File

@@ -21,10 +21,11 @@ Name | Type | Description | Notes
**mimeType** | **String** | |
**duration** | **String** | |
**webpPath** | **String** | |
**encodedVideoPath** | **String** | |
**encodedVideoPath** | **String** | | [optional]
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**livePhotoVideoId** | **String** | |
**livePhotoVideoId** | **String** | | [optional]
**tags** | [**List<TagResponseDto>**](TagResponseDto.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,105 +0,0 @@
# openapi.api.ConfigApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getSystemConfig**](ConfigApi.md#getsystemconfig) | **GET** /config/system |
[**updateSystemConfig**](ConfigApi.md#updatesystemconfig) | **PUT** /config/system |
# **getSystemConfig**
> SystemConfigResponseDto getSystemConfig()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = ConfigApi();
try {
final result = api_instance.getSystemConfig();
print(result);
} catch (e) {
print('Exception when calling ConfigApi->getSystemConfig: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateSystemConfig**
> SystemConfigResponseDto updateSystemConfig(body)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = ConfigApi();
final body = Object(); // Object |
try {
final result = api_instance.updateSystemConfig(body);
print(result);
} catch (e) {
print('Exception when calling ConfigApi->updateSystemConfig: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**body** | **Object**| |
### Return type
[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -1,4 +1,4 @@
# openapi.model.AssetCountResponseDto
# openapi.model.CreateTagDto
## Load the model package
```dart
@@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**photos** | **int** | |
**videos** | **int** | |
**type** | [**TagTypeEnum**](TagTypeEnum.md) | |
**name** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -11,13 +11,16 @@ Method | HTTP request | Description
------------- | ------------- | -------------
[**createDeviceInfo**](DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
[**updateDeviceInfo**](DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
[**upsertDeviceInfo**](DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
# **createDeviceInfo**
> DeviceInfoResponseDto createDeviceInfo(createDeviceInfoDto)
> DeviceInfoResponseDto createDeviceInfo(upsertDeviceInfoDto)
@deprecated
### Example
```dart
import 'package:openapi/api.dart';
@@ -29,10 +32,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = DeviceInfoApi();
final createDeviceInfoDto = CreateDeviceInfoDto(); // CreateDeviceInfoDto |
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
try {
final result = api_instance.createDeviceInfo(createDeviceInfoDto);
final result = api_instance.createDeviceInfo(upsertDeviceInfoDto);
print(result);
} catch (e) {
print('Exception when calling DeviceInfoApi->createDeviceInfo: $e\n');
@@ -43,7 +46,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**createDeviceInfoDto** | [**CreateDeviceInfoDto**](CreateDeviceInfoDto.md)| |
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
### Return type
@@ -61,10 +64,12 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateDeviceInfo**
> DeviceInfoResponseDto updateDeviceInfo(updateDeviceInfoDto)
> DeviceInfoResponseDto updateDeviceInfo(upsertDeviceInfoDto)
@deprecated
### Example
```dart
import 'package:openapi/api.dart';
@@ -76,10 +81,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = DeviceInfoApi();
final updateDeviceInfoDto = UpdateDeviceInfoDto(); // UpdateDeviceInfoDto |
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
try {
final result = api_instance.updateDeviceInfo(updateDeviceInfoDto);
final result = api_instance.updateDeviceInfo(upsertDeviceInfoDto);
print(result);
} catch (e) {
print('Exception when calling DeviceInfoApi->updateDeviceInfo: $e\n');
@@ -90,7 +95,54 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**updateDeviceInfoDto** | [**UpdateDeviceInfoDto**](UpdateDeviceInfoDto.md)| |
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
### Return type
[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **upsertDeviceInfo**
> DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = DeviceInfoApi();
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
try {
final result = api_instance.upsertDeviceInfo(upsertDeviceInfoDto);
print(result);
} catch (e) {
print('Exception when calling DeviceInfoApi->upsertDeviceInfo: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
### Return type

View File

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

View File

@@ -10,11 +10,12 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config |
[**getDefaults**](SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults |
[**updateConfig**](SystemConfigApi.md#updateconfig) | **PUT** /system-config |
# **getConfig**
> SystemConfigResponseDto getConfig()
> SystemConfigDto getConfig()
@@ -43,7 +44,7 @@ This endpoint does not need any parameter.
### Return type
[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
[**SystemConfigDto**](SystemConfigDto.md)
### Authorization
@@ -56,8 +57,8 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateConfig**
> SystemConfigResponseDto updateConfig(body)
# **getDefaults**
> SystemConfigDto getDefaults()
@@ -72,10 +73,53 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SystemConfigApi();
final body = Object(); // Object |
try {
final result = api_instance.updateConfig(body);
final result = api_instance.getDefaults();
print(result);
} catch (e) {
print('Exception when calling SystemConfigApi->getDefaults: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**SystemConfigDto**](SystemConfigDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateConfig**
> SystemConfigDto updateConfig(systemConfigDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SystemConfigApi();
final systemConfigDto = SystemConfigDto(); // SystemConfigDto |
try {
final result = api_instance.updateConfig(systemConfigDto);
print(result);
} catch (e) {
print('Exception when calling SystemConfigApi->updateConfig: $e\n');
@@ -86,11 +130,11 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**body** | **Object**| |
**systemConfigDto** | [**SystemConfigDto**](SystemConfigDto.md)| |
### Return type
[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
[**SystemConfigDto**](SystemConfigDto.md)
### Authorization

View File

@@ -1,4 +1,4 @@
# openapi.model.AdminConfigResponseDto
# openapi.model.SystemConfigDto
## Load the model package
```dart
@@ -8,7 +8,8 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**config** | [**Object**](.md) | |
**ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | |
**oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

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

View File

@@ -1,4 +1,4 @@
# openapi.model.AssetCountByTimeGroupResponseDto
# openapi.model.SystemConfigFFmpegDto
## Load the model package
```dart
@@ -8,8 +8,11 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**count** | **int** | |
**buckets** | [**List<AssetCountByTimeBucketResponseDto>**](AssetCountByTimeBucketResponseDto.md) | | [default to const []]
**crf** | **String** | |
**preset** | **String** | |
**targetVideoCodec** | **String** | |
**targetAudioCodec** | **String** | |
**targetScaling** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

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

View File

@@ -1,4 +1,4 @@
# openapi.model.AssetCountByTimeGroupDto
# openapi.model.SystemConfigOAuthDto
## Load the model package
```dart
@@ -8,8 +8,13 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**timeGroup** | **String** | |
**count** | **int** | |
**enabled** | **bool** | |
**issuerUrl** | **String** | |
**clientId** | **String** | |
**clientSecret** | **String** | |
**scope** | **String** | |
**buttonText** | **String** | |
**autoRegister** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,15 +0,0 @@
# openapi.model.SystemConfigResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**config** | [**List<SystemConfigResponseItem>**](SystemConfigResponseItem.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

220
mobile/openapi/doc/TagApi.md generated Normal file
View File

@@ -0,0 +1,220 @@
# openapi.api.TagApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**create**](TagApi.md#create) | **POST** /tag |
[**delete**](TagApi.md#delete) | **DELETE** /tag/{id} |
[**findAll**](TagApi.md#findall) | **GET** /tag |
[**findOne**](TagApi.md#findone) | **GET** /tag/{id} |
[**update**](TagApi.md#update) | **PATCH** /tag/{id} |
# **create**
> TagResponseDto create(createTagDto)
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = TagApi();
final createTagDto = CreateTagDto(); // CreateTagDto |
try {
final result = api_instance.create(createTagDto);
print(result);
} catch (e) {
print('Exception when calling TagApi->create: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**createTagDto** | [**CreateTagDto**](CreateTagDto.md)| |
### Return type
[**TagResponseDto**](TagResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **delete**
> delete(id)
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = TagApi();
final id = id_example; // String |
try {
api_instance.delete(id);
} catch (e) {
print('Exception when calling TagApi->delete: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
void (empty response body)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **findAll**
> List<TagResponseDto> findAll()
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = TagApi();
try {
final result = api_instance.findAll();
print(result);
} catch (e) {
print('Exception when calling TagApi->findAll: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**List<TagResponseDto>**](TagResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **findOne**
> TagResponseDto findOne(id)
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = TagApi();
final id = id_example; // String |
try {
final result = api_instance.findOne(id);
print(result);
} catch (e) {
print('Exception when calling TagApi->findOne: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
[**TagResponseDto**](TagResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **update**
> TagResponseDto update(id, updateTagDto)
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = TagApi();
final id = id_example; // String |
final updateTagDto = UpdateTagDto(); // UpdateTagDto |
try {
final result = api_instance.update(id, updateTagDto);
print(result);
} catch (e) {
print('Exception when calling TagApi->update: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**updateTagDto** | [**UpdateTagDto**](UpdateTagDto.md)| |
### Return type
[**TagResponseDto**](TagResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -1,4 +1,4 @@
# openapi.model.SystemConfigResponseItem
# openapi.model.TagResponseDto
## Load the model package
```dart
@@ -8,10 +8,11 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**type** | [**TagTypeEnum**](TagTypeEnum.md) | |
**name** | **String** | |
**key** | [**SystemConfigKey**](SystemConfigKey.md) | |
**value** | **String** | |
**defaultValue** | **String** | |
**userId** | **String** | |
**renameTagId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,4 +1,4 @@
# openapi.model.JobType
# openapi.model.TagTypeEnum
## Load the model package
```dart

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