Compare commits
103 Commits
v1.27.0_37
...
v1.31.0_49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471a60dcb0 | ||
|
|
46994c3355 | ||
|
|
642811869c | ||
|
|
3be4697487 | ||
|
|
a3aca4acb5 | ||
|
|
7587f858ae | ||
|
|
854c214bc0 | ||
|
|
5dfce4db34 | ||
|
|
95467fa3c1 | ||
|
|
4ec3453558 | ||
|
|
536fda04f2 | ||
|
|
2094204877 | ||
|
|
ab375cca1a | ||
|
|
479f706f8a | ||
|
|
4342285507 | ||
|
|
8bb656cb17 | ||
|
|
3f1f835df3 | ||
|
|
87ca031335 | ||
|
|
96b9e37461 | ||
|
|
0d3a2fe844 | ||
|
|
848781aef5 | ||
|
|
28bf497a0b | ||
|
|
8ede738396 | ||
|
|
40c2b6a563 | ||
|
|
3581cf7305 | ||
|
|
c33775b944 | ||
|
|
b0cd2522e0 | ||
|
|
c3979f6e31 | ||
|
|
103df4d9f3 | ||
|
|
040e02cfc5 | ||
|
|
f377b64065 | ||
|
|
e5459b68ff | ||
|
|
fc194021a4 | ||
|
|
39f8ca3bf1 | ||
|
|
7a807f7216 | ||
|
|
bedfb51b1c | ||
|
|
b2afb95c19 | ||
|
|
10239161fd | ||
|
|
242f10952d | ||
|
|
e997bd371b | ||
|
|
400167f4ef | ||
|
|
572f6d833d | ||
|
|
2e06be5155 | ||
|
|
62121470a8 | ||
|
|
e3ccc3ee6b | ||
|
|
ece94f6bdc | ||
|
|
03fc0703c0 | ||
|
|
0d13b25f56 | ||
|
|
75c2067836 | ||
|
|
824da6a07b | ||
|
|
2c2ea24dc4 | ||
|
|
47b73a5b64 | ||
|
|
6b3f8e548d | ||
|
|
0ea483f901 | ||
|
|
97aed8ef23 | ||
|
|
0ee3fe9157 | ||
|
|
434770155f | ||
|
|
7e8bf94543 | ||
|
|
8d8944705c | ||
|
|
7c9c1a5169 | ||
|
|
1a6c16d8ea | ||
|
|
ccf792f9d3 | ||
|
|
789bc8563c | ||
|
|
99a50f70dd | ||
|
|
9bef411056 | ||
|
|
e79e92c60f | ||
|
|
858ad43d3b | ||
|
|
5761765ea7 | ||
|
|
6abc733763 | ||
|
|
4271e24e59 | ||
|
|
9e4ed2214b | ||
|
|
011332e509 | ||
|
|
5403ef4d84 | ||
|
|
31739aca02 | ||
|
|
8f2e7b6f65 | ||
|
|
4ed647c43d | ||
|
|
f88ff4fb5c | ||
|
|
cc4881d633 | ||
|
|
d856b35afc | ||
|
|
b6d025da09 | ||
|
|
cc79ff1ca3 | ||
|
|
131aa2b6be | ||
|
|
02a6b73122 | ||
|
|
d87366c095 | ||
|
|
4f7a3afbfc | ||
|
|
6725954b70 | ||
|
|
4fe535e5e8 | ||
|
|
aed94bfc4c | ||
|
|
de996c0a81 | ||
|
|
1a39aa4da5 | ||
|
|
1f4ba73da7 | ||
|
|
836b174d33 | ||
|
|
853a65aef1 | ||
|
|
566039b93f | ||
|
|
18a7ff8726 | ||
|
|
6ffdf167fe | ||
|
|
6b702b13e4 | ||
|
|
f476bd985b | ||
|
|
92c4f0598b | ||
|
|
a337402124 | ||
|
|
209e6332b3 | ||
|
|
645bd8a109 | ||
|
|
9a471d80f7 |
@@ -2,8 +2,6 @@ name: Build and Push Docker Image - Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -38,6 +36,7 @@ jobs:
|
||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||
tags: |
|
||||
altran1502/immich-server:staging
|
||||
altran1502/immich-server:${{ github.event.pull_request.number }}
|
||||
|
||||
build_and_push_machine_learning_staging:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -67,6 +66,7 @@ jobs:
|
||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||
tags: |
|
||||
altran1502/immich-machine-learning:staging
|
||||
altran1502/immich-machine-learning:${{ github.event.pull_request.number }}
|
||||
|
||||
build_and_push_web_staging:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -96,6 +96,7 @@ jobs:
|
||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||
tags: |
|
||||
altran1502/immich-web:staging
|
||||
altran1502/immich-web:${{ github.event.pull_request.number }}
|
||||
|
||||
build_and_push_nginx_staging:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -124,3 +125,4 @@ jobs:
|
||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||
tags: |
|
||||
altran1502/immich-proxy:staging
|
||||
altran1502/immich-proxy:${{ github.event.pull_request.number }}
|
||||
|
||||
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '20 13 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
23
.github/workflows/test.yml
vendored
23
.github/workflows/test.yml
vendored
@@ -13,18 +13,29 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run Immich Server 2E2 Test
|
||||
- name: Run Immich Server E2E Test
|
||||
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
||||
|
||||
unit-tests:
|
||||
name: Run unit test suites
|
||||
server-unit-tests:
|
||||
name: Run server unit test suites and checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm install && npm run test
|
||||
run: cd server && npm ci && npm run check:all
|
||||
|
||||
web-unit-tests:
|
||||
name: Run web unit test suites and checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run tests
|
||||
run: cd web && npm ci && npm run check:all
|
||||
|
||||
44
README.md
44
README.md
@@ -23,11 +23,30 @@
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
## Demo
|
||||
|
||||
You can access the web demo at https://demo.immich.app
|
||||
|
||||
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
|
||||
|
||||
|
||||
```
|
||||
The credential
|
||||
email: demo@immich.app
|
||||
password: demo
|
||||
```
|
||||
|
||||
```
|
||||
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
```
|
||||
|
||||
## Content
|
||||
- [Features](#features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Installation](#installation)
|
||||
- [Mobile App](#-mobile-app)
|
||||
- [Update](#update)
|
||||
- [Mobile App](#mobile-app)
|
||||
- [App Beta Invitation links](#App-Beta-release-channel)
|
||||
- [Development](#development)
|
||||
- [Support](#support)
|
||||
- [Known Issues](#known-issues)
|
||||
@@ -97,6 +116,8 @@ There are several services that compose Immich:
|
||||
|
||||
# Installation
|
||||
|
||||
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
|
||||
|
||||
## Testing One-step installation (not recommended for production)
|
||||
|
||||
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
||||
@@ -143,7 +164,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
||||
* Populate custom database information if necessary.
|
||||
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
||||
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
||||
* [Optional] Populate Mapbox value to use reverse geocoding.
|
||||
|
||||
### Step 3 - Start the containers
|
||||
|
||||
@@ -170,15 +190,27 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
||||
|
||||
<br/>
|
||||
|
||||
## Update
|
||||
|
||||
If you have installed, you can update the application by navigate to the directory that contains the `docker-compose.yml` file and run the following command:
|
||||
|
||||
```bash
|
||||
docker-compose pull && docker-compose up -d
|
||||
```
|
||||
|
||||
# Mobile app
|
||||
|
||||
| F-Droid | Google Play | iOS |
|
||||
| - | - | - |
|
||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> |
|
||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
|
||||
|
||||
> *The App version might be lagging behind the latest release due to the review process.*
|
||||
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
|
||||
|
||||
# App Beta release channel
|
||||
|
||||
You can opt-in to join app beta release channel by following the links below:
|
||||
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
|
||||
<br/>
|
||||
|
||||
# Development
|
||||
@@ -225,7 +257,7 @@ Cheers! 🎉
|
||||
|
||||
## TensorFlow Build Issue
|
||||
|
||||
*This is a known issue for incorrect Promox setup*
|
||||
*This is a known issue for incorrect Proxmox setup*
|
||||
|
||||
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
|
||||
|
||||
@@ -233,7 +265,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
|
||||
more /proc/cpuinfo | grep flags
|
||||
```
|
||||
|
||||
If you are running virtualization in Promox, the VM doesn't have the flag enabled.
|
||||
If you are running virtualization in Proxmox, the VM doesn't have the flag enabled.
|
||||
|
||||
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
||||
|
||||
|
||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `alex.tran1502@gmail.com`
|
||||
32
dev-setup.md
Normal file
32
dev-setup.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Development Setup
|
||||
|
||||
## Lint / format extensions
|
||||
|
||||
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
|
||||
|
||||
### VSCode
|
||||
Install Prettier, ESLint and Svelte extensions.
|
||||
|
||||
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript][typescript][css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"eslint.validate": ["javascript", "svelte"]
|
||||
}
|
||||
```
|
||||
|
||||
## Running tests / checks
|
||||
|
||||
In both server and web:
|
||||
`npm run check:all`
|
||||
@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
|
||||
# Optional Database settings:
|
||||
# DB_PORT=5432
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# Redis
|
||||
###################################################################################
|
||||
@@ -25,18 +22,17 @@ REDIS_HOSTNAME=immich_redis
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_SOCKET=
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# Upload File Config
|
||||
###################################################################################
|
||||
|
||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||
|
||||
###################################################################################
|
||||
# Log message level - [simple|verbose]
|
||||
###################################################################################
|
||||
|
||||
|
||||
LOG_LEVEL=simple
|
||||
|
||||
###################################################################################
|
||||
# JWT SECRET
|
||||
@@ -44,17 +40,21 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
|
||||
|
||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# MAPBOX
|
||||
# Reverse Geocoding
|
||||
####################################################################################
|
||||
|
||||
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||
ENABLE_MAPBOX=false
|
||||
MAPBOX_KEY=
|
||||
# DISABLE_REVERSE_GEOCODING=false
|
||||
|
||||
# Reverse geocoding is done locally which has a small impact on memory usage
|
||||
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
|
||||
# This ranges from 0-3 with 3 being the most precise
|
||||
# 3 - Cities > 500 population: ~200MB RAM
|
||||
# 2 - Cities > 1000 population: ~150MB RAM
|
||||
# 1 - Cities > 5000 population: ~80MB RAM
|
||||
# 0 - Cities > 15000 population: ~40MB RAM
|
||||
|
||||
# REVERSE_GEOCODING_PRECISION=3
|
||||
|
||||
####################################################################################
|
||||
# WEB - Optional
|
||||
@@ -63,4 +63,4 @@ MAPBOX_KEY=
|
||||
# Custom message on the login page, should be written in HTML form.
|
||||
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
||||
|
||||
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||
|
||||
@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
|
||||
|
||||
# WEB
|
||||
MAPBOX_KEY=
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
|
||||
@@ -102,8 +102,7 @@ services:
|
||||
context: ../nginx
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
|
||||
@@ -72,8 +72,7 @@ services:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:staging
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:release
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
|
||||
36
install.sh
36
install.sh
@@ -2,12 +2,17 @@ echo "Starting Immich installation..."
|
||||
|
||||
ip_address=$(hostname -I | awk '{print $1}')
|
||||
|
||||
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
|
||||
grep '"tag_name":' |
|
||||
sed -E 's/.*"([^"]+)".*/\1/')
|
||||
RED='\033[0;31m'
|
||||
GREEN='\032[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
machine_has() {
|
||||
type "$1" >/dev/null 2>&1
|
||||
get_release_version() {
|
||||
curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
|
||||
grep '"tag_name":' | # Get tag line
|
||||
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
|
||||
}
|
||||
|
||||
create_immich_directory() {
|
||||
@@ -17,12 +22,12 @@ create_immich_directory() {
|
||||
|
||||
download_docker_compose_file() {
|
||||
echo "Downloading docker-compose.yml..."
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
|
||||
}
|
||||
|
||||
download_dot_env_file() {
|
||||
echo "Downloading .env file..."
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
|
||||
}
|
||||
|
||||
populate_upload_location() {
|
||||
@@ -45,18 +50,21 @@ populate_upload_location() {
|
||||
start_docker_compose() {
|
||||
echo "Starting Immich's docker containers"
|
||||
|
||||
if machine_has "docker compose"; then {
|
||||
docker compose up --remove-orphans -d
|
||||
|
||||
show_friendly_message
|
||||
exit 0
|
||||
}; fi
|
||||
|
||||
if machine_has "docker-compose"; then
|
||||
docker-compose up --remove-orphans -d
|
||||
if docker compose &>/dev/null; then
|
||||
docker_bin="docker compose"
|
||||
elif docker-compose &>/dev/null; then
|
||||
docker_bin="docker-compose"
|
||||
else
|
||||
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $docker_bin up --remove-orphans -d; then
|
||||
show_friendly_message
|
||||
exit 0
|
||||
else
|
||||
echo "Could not start. Check for errors above."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -65,7 +73,7 @@ show_friendly_message() {
|
||||
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
||||
echo "The backup (or upload) location is $upload_location"
|
||||
echo "---------------------------------------------------"
|
||||
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||
|
||||
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,
|
||||
|
||||
|
||||
2227
machine-learning/package-lock.json
generated
2227
machine-learning/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/mapped-types": "^1.0.1",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/typeorm": "^8.0.3",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||
"@tensorflow/tfjs": "^3.19.0",
|
||||
@@ -34,11 +33,9 @@
|
||||
"@tensorflow/tfjs-node": "^3.19.0",
|
||||
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
||||
"@trpc/server": "^9.20.3",
|
||||
"pg": "^8.7.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"typeorm": "^0.2.45"
|
||||
"rxjs": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.2.4",
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
||||
import { databaseConfig } from './config/database.config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
ImageClassifierModule,
|
||||
ObjectDetectionModule,
|
||||
],
|
||||
imports: [ImageClassifierModule, ObjectDetectionModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const databaseConfig: TypeOrmModuleOptions = {
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOSTNAME || 'immich_postgres',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE_NAME,
|
||||
synchronize: false,
|
||||
};
|
||||
@@ -51,7 +51,7 @@ android {
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 21
|
||||
minSdkVersion 23
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich">
|
||||
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich" xmlns:tools="http://schemas.android.com/tools">
|
||||
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
|
||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@@ -15,8 +15,12 @@
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||
|
||||
|
||||
<!-- Disables default WorkManager initialization to use our custom initialization -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove">
|
||||
</provider>
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -15,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
|
||||
* Android plugin for Dart `BackgroundService`
|
||||
*
|
||||
* Receives messages/method calls from the foreground Dart side to manage
|
||||
* the background service, e.g. start (enqueue), stop (cancel)
|
||||
* the background service, e.g. start (enqueue), stop (cancel)
|
||||
*/
|
||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
@@ -43,31 +38,32 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context!!
|
||||
when(call.method) {
|
||||
"initialize" -> { // needs to be called prior to any other method
|
||||
when (call.method) {
|
||||
"enable" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
|
||||
.edit()
|
||||
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
|
||||
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
|
||||
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
|
||||
.apply()
|
||||
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
|
||||
result.success(true)
|
||||
}
|
||||
"start" -> {
|
||||
"configure" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val immediate = args.get(0) as Boolean
|
||||
val keepExisting = args.get(1) as Boolean
|
||||
val requireUnmeteredNetwork = args.get(2) as Boolean
|
||||
val requireCharging = args.get(3) as Boolean
|
||||
val notificationTitle = args.get(4) as String
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
|
||||
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
||||
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||
val requireCharging = args.get(1) as Boolean
|
||||
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
||||
result.success(true)
|
||||
}
|
||||
"stop" -> {
|
||||
"disable" -> {
|
||||
ContentObserverWorker.disable(ctx)
|
||||
BackupWorker.stopWork(ctx)
|
||||
result.success(true)
|
||||
}
|
||||
"isEnabled" -> {
|
||||
result.success(BackupWorker.isEnabled(ctx))
|
||||
result.success(ContentObserverWorker.isEnabled(ctx))
|
||||
}
|
||||
"isIgnoringBatteryOptimizations" -> {
|
||||
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
@@ -8,17 +9,12 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.provider.MediaStore
|
||||
import android.provider.BaseColumns
|
||||
import android.provider.MediaStore.MediaColumns
|
||||
import android.provider.MediaStore.Images.Media
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.NetworkType
|
||||
@@ -26,6 +22,7 @@ import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkInfo
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
@@ -41,14 +38,7 @@ import java.util.concurrent.TimeUnit
|
||||
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
|
||||
* `background.service.dart` to run the actual backup logic.
|
||||
* Called by Android WorkManager when all constraints for the work are met,
|
||||
* i.e. a new photo/video is created on the device AND battery is not low.
|
||||
* Optionally, unmetered network (wifi) and charging can be required.
|
||||
* As this work is not triggered periodically, but on content change, the
|
||||
* worker enqueues itself again with the same settings.
|
||||
* In case the worker is stopped by the system (e.g. constraints like wifi
|
||||
* are no longer met, or the system needs memory resources for more other
|
||||
* more important work), the worker is replaced without the constraint on
|
||||
* changed contents to run again as soon as deemed possible by the system.
|
||||
* i.e. battery is not low and optionally Wifi and charging are active.
|
||||
*/
|
||||
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
|
||||
|
||||
@@ -57,14 +47,15 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
private lateinit var backgroundChannel: MethodChannel
|
||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||
private var timeBackupStarted: Long = 0L
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
private var notificationDetailBuilder: NotificationCompat.Builder? = null
|
||||
|
||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||
|
||||
Log.d(TAG, "startWork")
|
||||
|
||||
val ctx = applicationContext
|
||||
// enqueue itself once again to continue to listen on added photos/videos
|
||||
enqueueMoreWork(ctx,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
|
||||
|
||||
if (!flutterLoader.initialized()) {
|
||||
flutterLoader.startInitialization(ctx)
|
||||
@@ -80,7 +71,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
// or by the system learning that immich is important to the user)
|
||||
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||
setForegroundAsync(createForegroundInfo(title))
|
||||
showInfo(getInfoBuilder(title, indeterminate=true).build())
|
||||
}
|
||||
engine = FlutterEngine(ctx)
|
||||
|
||||
@@ -115,6 +106,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "onStopped")
|
||||
// called when the system has to stop this worker because constraints are
|
||||
// no longer met or the system needs resources for more important tasks
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
@@ -130,24 +122,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
|
||||
private fun stopEngine(result: Result?) {
|
||||
if (result != null) {
|
||||
Log.d(TAG, "stopEngine result=${result}")
|
||||
resolvableFuture.set(result)
|
||||
} else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
|
||||
// stopped by system and this is the first time (content change constraints active)
|
||||
// replace the task without the content constraints to finish the backup as soon as possible
|
||||
enqueueMoreWork(applicationContext,
|
||||
immediate = true,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||
initialDelayInMs = ONE_MINUTE,
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
clearBackgroundNotification()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialized" ->
|
||||
"initialized" -> {
|
||||
timeBackupStarted = SystemClock.uptimeMillis()
|
||||
backgroundChannel.invokeMethod(
|
||||
"onAssetsChanged",
|
||||
null,
|
||||
@@ -163,46 +149,49 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
override fun success(receivedResult: Any?) {
|
||||
val success = receivedResult as Boolean
|
||||
stopEngine(if(success) Result.success() else Result.retry())
|
||||
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
|
||||
// there was an error (e.g. server not available)
|
||||
// replace the task without the content constraints to finish the backup as soon as possible
|
||||
enqueueMoreWork(applicationContext,
|
||||
immediate = true,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||
initialDelayInMs = ONE_MINUTE,
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
"updateNotification" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
setForegroundAsync(createForegroundInfo(title, content))
|
||||
val title = args.get(0) as String?
|
||||
val content = args.get(1) as String?
|
||||
val progress = args.get(2) as Int
|
||||
val max = args.get(3) as Int
|
||||
val indeterminate = args.get(4) as Boolean
|
||||
val isDetail = args.get(5) as Boolean
|
||||
val onlyIfFG = args.get(6) as Boolean
|
||||
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
|
||||
showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
|
||||
}
|
||||
}
|
||||
"showError" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
val content = args.get(1) as String?
|
||||
val individualTag = args.get(2) as String?
|
||||
showError(title, content, individualTag)
|
||||
}
|
||||
"clearErrorNotifications" -> clearErrorNotifications()
|
||||
"hasContentChanged" -> {
|
||||
val lastChange = applicationContext
|
||||
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
|
||||
val hasContentChanged = lastChange > timeBackupStarted;
|
||||
timeBackupStarted = SystemClock.uptimeMillis()
|
||||
r.success(hasContentChanged)
|
||||
}
|
||||
else -> r.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(title: String, content: String, individualTag: String?) {
|
||||
private fun showError(title: String, content: String?, individualTag: String?) {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
||||
}
|
||||
@@ -211,111 +200,116 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
}
|
||||
private fun clearBackgroundNotification() {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
|
||||
}
|
||||
|
||||
private fun showInfo(notification: Notification, isDetail: Boolean = false) {
|
||||
val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
setForegroundAsync(ForegroundInfo(id, notification))
|
||||
} else {
|
||||
notificationManager.notify(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInfoBuilder(
|
||||
title: String? = null,
|
||||
content: String? = null,
|
||||
isDetail: Boolean = false,
|
||||
progress: Int = 0,
|
||||
max: Int = 0,
|
||||
indeterminate: Boolean = false,
|
||||
): NotificationCompat.Builder {
|
||||
var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
|
||||
if (builder == null) {
|
||||
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
if (isDetail) {
|
||||
notificationDetailBuilder = builder
|
||||
} else {
|
||||
notificationBuilder = builder
|
||||
}
|
||||
}
|
||||
if (title != null) {
|
||||
builder.setTicker(title).setContentTitle(title)
|
||||
}
|
||||
if (content != null) {
|
||||
builder.setContentText(content)
|
||||
}
|
||||
return builder.setProgress(max, progress, indeterminate)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createChannel() {
|
||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||
notificationManager.createNotificationChannel(foreground)
|
||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
|
||||
notificationManager.createNotificationChannel(error)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREF_NAME = "immichBackgroundService"
|
||||
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
|
||||
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
|
||||
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
|
||||
const val SHARED_PREF_LAST_CHANGE = "lastChange"
|
||||
|
||||
private const val TASK_NAME = "immich/photoListener"
|
||||
private const val DATA_KEY_UNMETERED = "unmetered"
|
||||
private const val DATA_KEY_CHARGING = "charging"
|
||||
private const val DATA_KEY_RETRIES = "retries"
|
||||
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_ERROR_ID = 2
|
||||
private const val ONE_MINUTE: Long = 60000
|
||||
private const val NOTIFICATION_DETAIL_ID = 3
|
||||
private const val ONE_MINUTE = 60000L
|
||||
|
||||
/**
|
||||
* Enqueues the `BackupWorker` to run when all constraints are met.
|
||||
*
|
||||
* @param context Android Context
|
||||
* @param immediate whether to enqueue(replace) the worker without the content change constraint
|
||||
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
|
||||
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
|
||||
* @param requireCharging if true, task only runs if device is charging
|
||||
* @param retries retry count (should be 0 unless an error occured and this is a retry)
|
||||
* Enqueues the BackupWorker to run once the constraints are met
|
||||
*/
|
||||
fun startWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
|
||||
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
||||
fun enqueueBackupWorker(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
delayMilliseconds: Long = 0L) {
|
||||
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
|
||||
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
|
||||
}
|
||||
|
||||
private fun enqueueMoreWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
initialDelayInMs: Long = 0,
|
||||
retries: Int = 0) {
|
||||
if (!isEnabled(context)) {
|
||||
return
|
||||
/**
|
||||
* Updates the constraints of an already enqueued BackupWorker
|
||||
*/
|
||||
fun updateBackupWorker(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false) {
|
||||
try {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
|
||||
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
|
||||
if (workInfoList != null) {
|
||||
for (workInfo in workInfoList) {
|
||||
if (workInfo.getState() == WorkInfo.State.ENQUEUED) {
|
||||
val workRequest = buildWorkRequest(requireWifi, requireCharging)
|
||||
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "updateBackupWorker failed: ${e}")
|
||||
}
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresCharging(requireCharging);
|
||||
if (!immediate) {
|
||||
constraints
|
||||
.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)
|
||||
}
|
||||
|
||||
val inputData = Data.Builder()
|
||||
.putBoolean(DATA_KEY_CHARGING, requireCharging)
|
||||
.putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork)
|
||||
.putInt(DATA_KEY_RETRIES, retries)
|
||||
.build()
|
||||
|
||||
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
||||
.setConstraints(constraints.build())
|
||||
.setInputData(inputData)
|
||||
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
ONE_MINUTE,
|
||||
TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
|
||||
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
|
||||
val result = op.getResult().get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently running worker (if any) and removes it from the work queue
|
||||
*/
|
||||
fun stopWork(context: Context) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
|
||||
Log.d(TAG, "stopWork: BackupWorker cancelled")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,12 +324,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has enabled the background backup service
|
||||
*/
|
||||
fun isEnabled(ctx: Context): Boolean {
|
||||
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
||||
private fun buildWorkRequest(requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresCharging(requireCharging)
|
||||
.build();
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
|
||||
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
return work
|
||||
}
|
||||
|
||||
private val flutterLoader = FlutterLoader()
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Operation
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Worker executed by Android WorkManager observing content changes (new photos/videos)
|
||||
*
|
||||
* Immediately enqueues the BackupWorker when running.
|
||||
* As this work is not triggered periodically, but on content change, the
|
||||
* worker enqueues itself again after each run.
|
||||
*/
|
||||
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (!isEnabled(applicationContext)) {
|
||||
return Result.failure()
|
||||
}
|
||||
if (getTriggeredContentUris().size > 0) {
|
||||
startBackupWorker(applicationContext, delayMilliseconds = 0)
|
||||
}
|
||||
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
|
||||
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
|
||||
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
|
||||
|
||||
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
|
||||
|
||||
/**
|
||||
* Enqueues the `ContentObserverWorker`.
|
||||
*
|
||||
* @param context Android Context
|
||||
*/
|
||||
fun enable(context: Context, immediate: Boolean = false) {
|
||||
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
||||
Log.d(TAG, "enabled ContentObserverWorker")
|
||||
if (immediate) {
|
||||
startBackupWorker(context, delayMilliseconds = 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the `BackupWorker` to run when all constraints are met.
|
||||
*
|
||||
* @param context Android Context
|
||||
* @param requireWifi if true, task only runs if connected to wifi
|
||||
* @param requireCharging if true, task only runs if device is charging
|
||||
*/
|
||||
fun configureWork(context: Context,
|
||||
requireWifi: Boolean = false,
|
||||
requireCharging: Boolean = false) {
|
||||
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)
|
||||
.apply()
|
||||
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently running worker (if any) and removes it from the work queue
|
||||
*/
|
||||
fun disable(context: Context) {
|
||||
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
|
||||
Log.d(TAG, "disabled ContentObserverWorker")
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has enabled the background backup service
|
||||
*/
|
||||
fun isEnabled(ctx: Context): Boolean {
|
||||
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue and replace the worker without the content trigger but with a short delay
|
||||
*/
|
||||
fun workManagerAppClearedWorkaround(context: Context) {
|
||||
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
|
||||
.setInitialDelay(500, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
|
||||
.getResult()
|
||||
.get()
|
||||
Log.d(TAG, "workManagerAppClearedWorkaround")
|
||||
}
|
||||
|
||||
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
|
||||
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)
|
||||
.build()
|
||||
|
||||
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
||||
}
|
||||
|
||||
fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
|
||||
return
|
||||
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
||||
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
||||
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
||||
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "ContentObserverWorker"
|
||||
@@ -0,0 +1,19 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
|
||||
class ImmichApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val config = Configuration.Builder().build()
|
||||
WorkManager.initialize(this, config)
|
||||
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||
// (because of low memory etc.), the backup is never performed.
|
||||
// As a workaround, we also run a backup check when initializing the application
|
||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package app.alextran.immich
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import android.os.Bundle
|
||||
import android.content.Intent
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Build Android"
|
||||
lane :build do
|
||||
desc "Build Android and Release Testing"
|
||||
lane :beta do
|
||||
gradle(
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 47,
|
||||
"android.injected.version.name" => "1.30.2",
|
||||
}
|
||||
)
|
||||
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', track: 'beta')
|
||||
end
|
||||
|
||||
desc "Build and Release Android"
|
||||
@@ -30,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 37,
|
||||
"android.injected.version.name" => "1.27.0",
|
||||
"android.injected.version.code" => 49,
|
||||
"android.injected.version.name" => "1.31.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')
|
||||
|
||||
@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
||||
|
||||
## Android
|
||||
|
||||
### android build
|
||||
### android beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android build
|
||||
[bundle exec] fastlane android beta
|
||||
```
|
||||
|
||||
Build Android
|
||||
Build Android and Release Testing
|
||||
|
||||
### android release
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed remove empty translations
|
||||
* Fixed search page crashes the app on some Android models
|
||||
@@ -0,0 +1 @@
|
||||
* Improve Android background service reliability
|
||||
@@ -0,0 +1 @@
|
||||
* Fix background service cannot run in release build
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed oversize play button on video
|
||||
* Fixed app crashing when swipe between assets
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed Android BackgroundServiceStartNotAllowedException
|
||||
* Restore old cache mechanism
|
||||
@@ -0,0 +1 @@
|
||||
* Update deprecated API that causes notification not dismissing after background upload progress finished.
|
||||
@@ -0,0 +1 @@
|
||||
* Fixed app crashes when there is no object detection result.
|
||||
@@ -0,0 +1 @@
|
||||
* Correctly display time based on timezone
|
||||
@@ -0,0 +1 @@
|
||||
* Added improvement for timeline view
|
||||
@@ -0,0 +1 @@
|
||||
* Improve scroll thumb date info
|
||||
@@ -0,0 +1 @@
|
||||
* Fixed parsing date error prevent timeline to be loaded.
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed run background service after being killed
|
||||
* Added background backup progress notifications
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"album_viewer_appbar_share_leave": "Album verlassen",
|
||||
"album_viewer_appbar_share_remove": "Entferne vom Album",
|
||||
"album_viewer_page_share_add_users": "Nutzer hinzufügen",
|
||||
"asset_list_settings_subtitle": "",
|
||||
"asset_list_settings_title": "",
|
||||
"backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})",
|
||||
"backup_album_selection_page_albums_tap": "Tippen um einzuschließen, doppelt tippen um zu entfernen",
|
||||
"backup_album_selection_page_assets_scatter": "Elemente können sich über mehrere Alben verteilen. Daher können diese vor der Sicherung eingeschlossen oder ausgeschlossen werden",
|
||||
@@ -21,26 +19,7 @@
|
||||
"backup_album_selection_page_selection_info": "Auswahl",
|
||||
"backup_album_selection_page_total_assets": "Elemente",
|
||||
"backup_all": "Alle",
|
||||
"backup_background_service_backup_failed_message": "",
|
||||
"backup_background_service_connection_failed_message": "",
|
||||
"backup_background_service_current_upload_notification": "",
|
||||
"backup_background_service_default_notification": "",
|
||||
"backup_background_service_error_title": "",
|
||||
"backup_background_service_in_progress_notification": "",
|
||||
"backup_background_service_upload_failure_notification": "",
|
||||
"backup_controller_page_albums": "Gesicherte Alben",
|
||||
"backup_controller_page_background_battery_info_link": "",
|
||||
"backup_controller_page_background_battery_info_message": "",
|
||||
"backup_controller_page_background_battery_info_ok": "",
|
||||
"backup_controller_page_background_battery_info_title": "",
|
||||
"backup_controller_page_background_charging": "",
|
||||
"backup_controller_page_background_configure_error": "",
|
||||
"backup_controller_page_background_description": "",
|
||||
"backup_controller_page_background_is_off": "",
|
||||
"backup_controller_page_background_is_on": "",
|
||||
"backup_controller_page_background_turn_off": "",
|
||||
"backup_controller_page_background_turn_on": "",
|
||||
"backup_controller_page_background_wifi": "",
|
||||
"backup_controller_page_backup": "Sicherung",
|
||||
"backup_controller_page_backup_selected": "Ausgewählt: ",
|
||||
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
|
||||
@@ -69,19 +48,6 @@
|
||||
"backup_controller_page_uploading_file_info": "Informationen",
|
||||
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
|
||||
"backup_info_card_assets": "Elemente",
|
||||
"cache_settings_album_thumbnails": "",
|
||||
"cache_settings_clear_cache_button": "",
|
||||
"cache_settings_clear_cache_button_title": "",
|
||||
"cache_settings_image_cache_size": "",
|
||||
"cache_settings_statistics_album": "",
|
||||
"cache_settings_statistics_assets": "",
|
||||
"cache_settings_statistics_full": "",
|
||||
"cache_settings_statistics_shared": "",
|
||||
"cache_settings_statistics_thumbnail": "",
|
||||
"cache_settings_statistics_title": "",
|
||||
"cache_settings_subtitle": "",
|
||||
"cache_settings_thumbnail_size": "",
|
||||
"cache_settings_title": "",
|
||||
"control_bottom_app_bar_delete": "Löschen",
|
||||
"control_bottom_app_bar_share": "Teilen",
|
||||
"create_album_page_untitled": "Unbenannt",
|
||||
@@ -127,13 +93,6 @@
|
||||
"select_additional_user_for_sharing_page_suggestions": "Vorschläge",
|
||||
"select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden",
|
||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||
"setting_notifications_notify_failures_grace_period": "",
|
||||
"setting_notifications_notify_hours": "",
|
||||
"setting_notifications_notify_immediately": "",
|
||||
"setting_notifications_notify_minutes": "",
|
||||
"setting_notifications_notify_never": "",
|
||||
"setting_notifications_subtitle": "",
|
||||
"setting_notifications_title": "",
|
||||
"setting_pages_app_bar_settings": "Einstellungen",
|
||||
"share_add": "Hinzufügen",
|
||||
"share_add_photos": "Fotos hinzufügen",
|
||||
@@ -150,8 +109,6 @@
|
||||
"tab_controller_nav_photos": "Fotos",
|
||||
"tab_controller_nav_search": "Suche",
|
||||
"tab_controller_nav_sharing": "Teilen",
|
||||
"theme_setting_asset_list_storage_indicator_title": "",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "",
|
||||
"theme_setting_dark_mode_switch": "Dunkler Modus",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
||||
|
||||
@@ -134,6 +134,10 @@
|
||||
"setting_notifications_notify_never": "never",
|
||||
"setting_notifications_subtitle": "Adjust your notification preferences",
|
||||
"setting_notifications_title": "Notifications",
|
||||
"setting_notifications_total_progress_title": "Show background backup total progress",
|
||||
"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_pages_app_bar_settings": "Settings",
|
||||
"share_add": "Add",
|
||||
"share_add_photos": "Add photos",
|
||||
@@ -165,5 +169,10 @@
|
||||
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||
"experimental_settings_title": "Experimental",
|
||||
"experimental_settings_subtitle": "Use at your own risk!",
|
||||
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||
"settings_require_restart": "Please restart Immich to apply this setting"
|
||||
}
|
||||
@@ -21,12 +21,8 @@
|
||||
"backup_controller_page_backup_selected": "Seleccionado:",
|
||||
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
|
||||
"backup_controller_page_cancel": "Cancelar",
|
||||
"backup_controller_page_created": "",
|
||||
"backup_controller_page_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos activos al servidor.",
|
||||
"backup_controller_page_excluded": "Excluido:",
|
||||
"backup_controller_page_failed": "",
|
||||
"backup_controller_page_filename": "",
|
||||
"backup_controller_page_id": "",
|
||||
"backup_controller_page_info": "Información de la Copia de Seguridad",
|
||||
"backup_controller_page_none_selected": "Ninguno seleccionado",
|
||||
"backup_controller_page_remainder": "Remanente",
|
||||
@@ -42,7 +38,6 @@
|
||||
"backup_controller_page_total_sub": "Todas las fotos y vídeos únicos de los álbumes seleccionados",
|
||||
"backup_controller_page_turn_off": "Apagar la copia de seguridad",
|
||||
"backup_controller_page_turn_on": "Activar la copia de seguridad",
|
||||
"backup_controller_page_uploading_file_info": "",
|
||||
"backup_err_only_album": "No se puede eliminar el único álbum",
|
||||
"backup_info_card_assets": "activos",
|
||||
"control_bottom_app_bar_delete": "Eliminar",
|
||||
@@ -67,7 +62,6 @@
|
||||
"login_form_err_invalid_email": "Correo electrónico no válido",
|
||||
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
|
||||
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
|
||||
"login_form_failed_login": "",
|
||||
"login_form_label_email": "Correo",
|
||||
"login_form_label_password": "Contraseña",
|
||||
"login_form_password_hint": "contraseña",
|
||||
@@ -76,14 +70,12 @@
|
||||
"profile_drawer_client_server_up_to_date": "El Cliente y el Servidor están actualizados",
|
||||
"profile_drawer_sign_out": "Cerrar Sesión",
|
||||
"search_bar_hint": "Busca tus fotos",
|
||||
"search_page_no_objects": "",
|
||||
"search_page_no_places": "No hay información de lugares disponibles",
|
||||
"search_page_places": "Lugares",
|
||||
"search_page_things": "Cosas",
|
||||
"search_result_page_new_search_hint": "Nueva Busqueda",
|
||||
"select_additional_user_for_sharing_page_suggestions": "Sugerencias",
|
||||
"select_user_for_sharing_page_err_album": "Fallo al crear el álbum",
|
||||
"select_user_for_sharing_page_share_suggestions": "",
|
||||
"share_add": "Añadir",
|
||||
"share_add_photos": "Añadir fotos",
|
||||
"share_add_title": "Añadir un título",
|
||||
|
||||
@@ -49,9 +49,6 @@
|
||||
"create_shared_album_page_share": "Jaa",
|
||||
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
|
||||
"create_shared_album_page_share_select_photos": "Valitse kuvat",
|
||||
"daily_title_text_date": "",
|
||||
"daily_title_text_date_year": "",
|
||||
"date_format": "",
|
||||
"delete_dialog_alert": "Nämä kohteet poistetaan pysyvästi Immich:stä ja laitteeltasi",
|
||||
"delete_dialog_cancel": "Peruuta",
|
||||
"delete_dialog_ok": "Poista",
|
||||
@@ -72,7 +69,6 @@
|
||||
"login_form_label_password": "Salasana",
|
||||
"login_form_password_hint": "salasana",
|
||||
"login_form_save_login": "Pysy kirjautuneena",
|
||||
"monthly_title_text_date_format": "",
|
||||
"profile_drawer_client_server_up_to_date": "Asiakassovellus ja palvelin ovat ajan tasalla",
|
||||
"profile_drawer_sign_out": "Kirjaudu ulos",
|
||||
"search_bar_hint": "Etsi kuvia",
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"album_viewer_appbar_share_leave": "Quitter l'album",
|
||||
"album_viewer_appbar_share_remove": "Retirer de l'album",
|
||||
"album_viewer_page_share_add_users": "Ajouter des utilisateurs",
|
||||
"asset_list_settings_subtitle": "",
|
||||
"asset_list_settings_title": "",
|
||||
"backup_album_selection_page_albums_device": "Albums sur l'appareil ({})",
|
||||
"backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure",
|
||||
"backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.",
|
||||
@@ -21,26 +19,7 @@
|
||||
"backup_album_selection_page_selection_info": "Informations sur la sélection",
|
||||
"backup_album_selection_page_total_assets": "Total des éléments uniques",
|
||||
"backup_all": "Tout",
|
||||
"backup_background_service_backup_failed_message": "",
|
||||
"backup_background_service_connection_failed_message": "",
|
||||
"backup_background_service_current_upload_notification": "",
|
||||
"backup_background_service_default_notification": "",
|
||||
"backup_background_service_error_title": "",
|
||||
"backup_background_service_in_progress_notification": "",
|
||||
"backup_background_service_upload_failure_notification": "",
|
||||
"backup_controller_page_albums": "Sauvegarder les albums",
|
||||
"backup_controller_page_background_battery_info_link": "",
|
||||
"backup_controller_page_background_battery_info_message": "",
|
||||
"backup_controller_page_background_battery_info_ok": "",
|
||||
"backup_controller_page_background_battery_info_title": "",
|
||||
"backup_controller_page_background_charging": "",
|
||||
"backup_controller_page_background_configure_error": "",
|
||||
"backup_controller_page_background_description": "",
|
||||
"backup_controller_page_background_is_off": "",
|
||||
"backup_controller_page_background_is_on": "",
|
||||
"backup_controller_page_background_turn_off": "",
|
||||
"backup_controller_page_background_turn_on": "",
|
||||
"backup_controller_page_background_wifi": "",
|
||||
"backup_controller_page_backup": "Sauvegardé",
|
||||
"backup_controller_page_backup_selected": "Sélectionné : ",
|
||||
"backup_controller_page_backup_sub": "Photos et vidéos sauvegardées",
|
||||
@@ -69,19 +48,6 @@
|
||||
"backup_controller_page_uploading_file_info": "Envoi d'informations sur le fichier",
|
||||
"backup_err_only_album": "Impossible de retirer le seul album",
|
||||
"backup_info_card_assets": "éléments",
|
||||
"cache_settings_album_thumbnails": "",
|
||||
"cache_settings_clear_cache_button": "",
|
||||
"cache_settings_clear_cache_button_title": "",
|
||||
"cache_settings_image_cache_size": "",
|
||||
"cache_settings_statistics_album": "",
|
||||
"cache_settings_statistics_assets": "",
|
||||
"cache_settings_statistics_full": "",
|
||||
"cache_settings_statistics_shared": "",
|
||||
"cache_settings_statistics_thumbnail": "",
|
||||
"cache_settings_statistics_title": "",
|
||||
"cache_settings_subtitle": "",
|
||||
"cache_settings_thumbnail_size": "",
|
||||
"cache_settings_title": "",
|
||||
"control_bottom_app_bar_delete": "Supprimer",
|
||||
"control_bottom_app_bar_share": "Partager",
|
||||
"create_album_page_untitled": "Sans titre",
|
||||
@@ -127,14 +93,6 @@
|
||||
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
|
||||
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
|
||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||
"setting_notifications_notify_failures_grace_period": "",
|
||||
"setting_notifications_notify_hours": "",
|
||||
"setting_notifications_notify_immediately": "",
|
||||
"setting_notifications_notify_minutes": "",
|
||||
"setting_notifications_notify_never": "",
|
||||
"setting_notifications_subtitle": "",
|
||||
"setting_notifications_title": "",
|
||||
"setting_pages_app_bar_settings": "",
|
||||
"share_add": "Ajouter",
|
||||
"share_add_photos": "Ajouter des photos",
|
||||
"share_add_title": "Ajouter un titre",
|
||||
@@ -150,16 +108,6 @@
|
||||
"tab_controller_nav_photos": "Photos",
|
||||
"tab_controller_nav_search": "Recherche",
|
||||
"tab_controller_nav_sharing": "Partage",
|
||||
"theme_setting_asset_list_storage_indicator_title": "",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "",
|
||||
"theme_setting_dark_mode_switch": "",
|
||||
"theme_setting_image_viewer_quality_subtitle": "",
|
||||
"theme_setting_image_viewer_quality_title": "",
|
||||
"theme_setting_system_theme_switch": "",
|
||||
"theme_setting_theme_subtitle": "",
|
||||
"theme_setting_theme_title": "",
|
||||
"theme_setting_three_stage_loading_subtitle": "",
|
||||
"theme_setting_three_stage_loading_title": "",
|
||||
"version_announcement_overlay_ack": "Confirmer",
|
||||
"version_announcement_overlay_release_notes": "notes de mise à jour",
|
||||
"version_announcement_overlay_text_1": "Bonjour, une nouvelle version de",
|
||||
|
||||
@@ -360,11 +360,11 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 62;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -495,11 +495,11 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 62;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -522,11 +522,11 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 62;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.27.0</string>
|
||||
<string>1.30.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>52</string>
|
||||
<string>62</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.27.0"
|
||||
version_number: "1.31.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.057077">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
const AlbumThumbnailCard({
|
||||
Key? key,
|
||||
required this.album,
|
||||
required this.cacheService,
|
||||
}) : super(key: key);
|
||||
|
||||
final AlbumResponseDto album;
|
||||
final CacheService cacheService;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
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/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -17,13 +14,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
const AlbumViewerThumbnail({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.cacheManager,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -126,7 +121,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheManager,
|
||||
cacheKey: asset.id,
|
||||
width: 300,
|
||||
height: 300,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -24,7 +22,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
var newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
||||
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
||||
@@ -114,7 +111,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
cacheKey: asset.id,
|
||||
width: 150,
|
||||
height: 150,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -16,7 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
@@ -26,7 +23,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
cacheKey: asset.id,
|
||||
width: 500,
|
||||
height: 500,
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.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/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
@@ -192,7 +191,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final bool showStorageIndicator =
|
||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
if (albumInfo.assets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
@@ -207,7 +205,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
asset: albumInfo.assets[index],
|
||||
assetList: albumInfo.assets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
|
||||
class LibraryPage extends HookConsumerWidget {
|
||||
const LibraryPage({Key? key}) : super(key: key);
|
||||
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
_buildCreateAlbumButton(),
|
||||
for (var album in albums)
|
||||
AlbumThumbnailCard(
|
||||
cacheService: cacheService,
|
||||
album: album,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
|
||||
height: 60,
|
||||
memCacheHeight: 200,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager:
|
||||
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
||||
imageUrl: getAlbumThumbnailUrl(album),
|
||||
cacheKey: album.albumThumbnailAssetId,
|
||||
httpHeaders: {
|
||||
|
||||
@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||
Text(
|
||||
DateFormat('date_format'.tr()).format(
|
||||
assetDetail.exifInfo!.dateTimeOriginal!,
|
||||
assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||
@@ -12,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
bool _zoomedIn = false;
|
||||
|
||||
static const int swipeThreshold = 100;
|
||||
late CachedNetworkImageProvider fullProvider;
|
||||
late CachedNetworkImageProvider previewProvider;
|
||||
late CachedNetworkImageProvider thumbnailProvider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -56,21 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
widget.isZoomedFunction();
|
||||
}
|
||||
|
||||
void _fireStartLoadingEvent() {
|
||||
widget.onLoadingStart();
|
||||
}
|
||||
|
||||
void _fireFinishedLoadingEvent() {
|
||||
widget.onLoadingCompleted();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(
|
||||
String url, String cacheKey, BaseCacheManager? cacheManager) {
|
||||
String url,
|
||||
String cacheKey,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
cacheKey: cacheKey,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (newStatus != _RemoteImageStatus.full) {
|
||||
_fireStartLoadingEvent();
|
||||
} else {
|
||||
_fireFinishedLoadingEvent();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
@@ -104,10 +92,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
void _loadImages() {
|
||||
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
|
||||
thumbnailProvider = _authorizedImageProvider(
|
||||
widget.thumbnailUrl,
|
||||
widget.cacheKey,
|
||||
widget.thumbnailCacheManager,
|
||||
);
|
||||
_imageProvider = thumbnailProvider;
|
||||
|
||||
@@ -121,10 +108,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
);
|
||||
|
||||
if (widget.previewUrl != null) {
|
||||
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
|
||||
previewProvider = _authorizedImageProvider(
|
||||
widget.previewUrl!,
|
||||
"${widget.cacheKey}_previewStage",
|
||||
widget.previewCacheManager,
|
||||
);
|
||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
@@ -133,10 +119,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
);
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
|
||||
fullProvider = _authorizedImageProvider(
|
||||
widget.imageUrl,
|
||||
"${widget.cacheKey}_fullStage",
|
||||
widget.fullCacheManager,
|
||||
);
|
||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
@@ -147,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_loadImages();
|
||||
super.initState();
|
||||
_loadImages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() async {
|
||||
super.dispose();
|
||||
|
||||
if (_status == _RemoteImageStatus.full) {
|
||||
await fullProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.preview) {
|
||||
await previewProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.thumbnail) {
|
||||
await thumbnailProvider.evict();
|
||||
}
|
||||
|
||||
await _imageProvider.evict();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +163,6 @@ class RemotePhotoView extends StatefulWidget {
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
this.previewUrl,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
this.thumbnailCacheManager,
|
||||
this.previewCacheManager,
|
||||
this.fullCacheManager,
|
||||
required this.cacheKey,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -175,11 +170,6 @@ class RemotePhotoView extends StatefulWidget {
|
||||
final String imageUrl;
|
||||
final String authToken;
|
||||
final String? previewUrl;
|
||||
final Function onLoadingCompleted;
|
||||
final Function onLoadingStart;
|
||||
final BaseCacheManager? thumbnailCacheManager;
|
||||
final BaseCacheManager? previewCacheManager;
|
||||
final BaseCacheManager? fullCacheManager;
|
||||
final String cacheKey;
|
||||
|
||||
final void Function() onSwipeDown;
|
||||
|
||||
@@ -24,11 +24,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
double iconSize = 18.0;
|
||||
|
||||
return AppBar(
|
||||
// iconTheme: IconThemeData(color: Colors.grey[100]),
|
||||
// actionsIconTheme: IconThemeData(color: Colors.grey[100]),
|
||||
foregroundColor: Colors.grey[100],
|
||||
toolbarHeight: 60,
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
|
||||
@@ -121,8 +121,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: () => {},
|
||||
onLoadingStart: () => {},
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
threeStageLoading: threeStageLoading.value,
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -19,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
final String authToken;
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
final void Function() isZoomedFunction;
|
||||
final void Function() onLoadingCompleted;
|
||||
final void Function() onLoadingStart;
|
||||
final bool threeStageLoading;
|
||||
|
||||
ImageViewerPage({
|
||||
@@ -30,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
required this.threeStageLoading,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -41,7 +36,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail =
|
||||
@@ -85,14 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
thumbnailCacheManager:
|
||||
cacheService.getCache(CacheType.thumbnail),
|
||||
previewCacheManager:
|
||||
cacheService.getCache(CacheType.imageViewerPreview),
|
||||
fullCacheManager:
|
||||
cacheService.getCache(CacheType.imageViewerFull),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
||||
_createChewieController() {
|
||||
chewieController = ChewieController(
|
||||
showOptions: true,
|
||||
showControlsOnInitialize: true,
|
||||
showControlsOnInitialize: false,
|
||||
videoPlayerController: videoPlayerController,
|
||||
autoPlay: true,
|
||||
autoInitialize: true,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -28,12 +27,11 @@ final backgroundServiceProvider = Provider(
|
||||
/// Background backup service
|
||||
class BackgroundService {
|
||||
static const String _portNameLock = "immichLock";
|
||||
BackgroundService();
|
||||
static const MethodChannel _foregroundChannel =
|
||||
MethodChannel('immich/foregroundChannel');
|
||||
static const MethodChannel _backgroundChannel =
|
||||
MethodChannel('immich/backgroundChannel');
|
||||
bool _isForegroundInitialized = false;
|
||||
static final NumberFormat numberFormat = NumberFormat("###0.##");
|
||||
bool _isBackgroundInitialized = false;
|
||||
CancellationToken? _cancellationToken;
|
||||
bool _canceledBySystem = false;
|
||||
@@ -42,33 +40,39 @@ class BackgroundService {
|
||||
SendPort? _waitingIsolate;
|
||||
ReceivePort? _rp;
|
||||
bool _errorGracePeriodExceeded = true;
|
||||
|
||||
bool get isForegroundInitialized {
|
||||
return _isForegroundInitialized;
|
||||
}
|
||||
int _uploadedAssetsCount = 0;
|
||||
int _assetsToUploadCount = 0;
|
||||
int _lastDetailProgressUpdate = 0;
|
||||
String _lastPrintedProgress = "";
|
||||
|
||||
bool get isBackgroundInitialized {
|
||||
return _isBackgroundInitialized;
|
||||
}
|
||||
|
||||
Future<bool> _initialize() async {
|
||||
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
|
||||
var result = await _foregroundChannel
|
||||
.invokeMethod('initialize', [callback.toRawHandle()]);
|
||||
_isForegroundInitialized = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Ensures that the background service is enqueued if enabled in settings
|
||||
Future<bool> resumeServiceIfEnabled() async {
|
||||
return await isBackgroundBackupEnabled() &&
|
||||
await startService(keepExisting: true);
|
||||
return await isBackgroundBackupEnabled() && await enableService();
|
||||
}
|
||||
|
||||
/// Enqueues the background service
|
||||
Future<bool> startService({
|
||||
bool immediate = false,
|
||||
bool keepExisting = false,
|
||||
Future<bool> enableService({bool immediate = false}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
|
||||
final String title =
|
||||
"backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel
|
||||
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the background service
|
||||
Future<bool> configureService({
|
||||
bool requireUnmetered = true,
|
||||
bool requireCharging = false,
|
||||
}) async {
|
||||
@@ -76,14 +80,9 @@ class BackgroundService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final String title =
|
||||
"backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'start',
|
||||
[immediate, keepExisting, requireUnmetered, requireCharging, title],
|
||||
'configure',
|
||||
[requireUnmetered, requireCharging],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
@@ -92,15 +91,12 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
/// Cancels the background service (if currently running) and removes it from work queue
|
||||
Future<bool> stopService() async {
|
||||
Future<bool> disableService() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final ok = await _foregroundChannel.invokeMethod('stop');
|
||||
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -113,9 +109,6 @@ class BackgroundService {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -128,9 +121,6 @@ class BackgroundService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
return await _foregroundChannel
|
||||
.invokeMethod('isIgnoringBatteryOptimizations');
|
||||
} catch (error) {
|
||||
@@ -139,22 +129,29 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
/// Updates the notification shown by the background service
|
||||
Future<bool> _updateNotification({
|
||||
required String title,
|
||||
Future<bool?> _updateNotification({
|
||||
String? title,
|
||||
String? content,
|
||||
int progress = 0,
|
||||
int max = 0,
|
||||
bool indeterminate = false,
|
||||
bool isDetail = false,
|
||||
bool onlyIfFG = false,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('updateNotification', [title, content]);
|
||||
return _backgroundChannel.invokeMethod<bool>(
|
||||
'updateNotification',
|
||||
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||
}
|
||||
return Future.value(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Shows a new priority notification
|
||||
@@ -187,7 +184,8 @@ class BackgroundService {
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
"[_clearErrorNotifications] failed to communicate with plugin");
|
||||
"[_clearErrorNotifications] failed to communicate with plugin",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -287,20 +285,14 @@ class BackgroundService {
|
||||
case "onAssetsChanged":
|
||||
final Future<bool> translationsLoaded = loadTranslations();
|
||||
try {
|
||||
_clearErrorNotifications();
|
||||
final bool hasAccess = await acquireLock();
|
||||
if (!hasAccess) {
|
||||
debugPrint("[_callHandler] could acquire lock, exiting");
|
||||
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||
return false;
|
||||
}
|
||||
await translationsLoaded;
|
||||
final bool ok = await _onAssetsChanged();
|
||||
if (ok) {
|
||||
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
|
||||
null) {
|
||||
Hive.box(backgroundBackupInfoBox)
|
||||
.put(backupFailedSince, DateTime.now());
|
||||
}
|
||||
return ok;
|
||||
} catch (error) {
|
||||
debugPrint(error.toString());
|
||||
@@ -333,17 +325,51 @@ class BackgroundService {
|
||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||
BackupService backupService = BackupService(apiService);
|
||||
AppSettingsService settingsService = AppSettingsService();
|
||||
|
||||
final Box<HiveBackupAlbums> box =
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
if (backupAlbumInfo == null) {
|
||||
_clearErrorNotifications();
|
||||
return true;
|
||||
}
|
||||
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||
|
||||
do {
|
||||
final bool backupOk = await _runBackup(
|
||||
backupService,
|
||||
settingsService,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
if (backupOk) {
|
||||
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||
await box.put(
|
||||
backupInfoKey,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
|
||||
null) {
|
||||
Hive.box(backgroundBackupInfoBox)
|
||||
.put(backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
}
|
||||
// check for new assets added while performing backup
|
||||
} while (true ==
|
||||
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService,
|
||||
AppSettingsService settingsService,
|
||||
HiveBackupAlbums backupAlbumInfo,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||
final bool notifyTotalProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||
final bool notifySingleProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
@@ -367,26 +393,29 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
_clearErrorNotifications();
|
||||
return true;
|
||||
}
|
||||
_assetsToUploadCount = toUpload.length;
|
||||
_uploadedAssetsCount = 0;
|
||||
_updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
|
||||
progress: 0,
|
||||
max: notifyTotalProgress ? _assetsToUploadCount : 0,
|
||||
indeterminate: !notifyTotalProgress,
|
||||
onlyIfFG: !notifyTotalProgress,
|
||||
);
|
||||
|
||||
_cancellationToken = CancellationToken();
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
_onAssetUploaded,
|
||||
_onProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
|
||||
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||
_onBackupError,
|
||||
);
|
||||
if (ok) {
|
||||
_clearErrorNotifications();
|
||||
await box.put(
|
||||
backupInfoKey,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
} else {
|
||||
if (!ok && !_cancellationToken!.isCancelled) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_backup_failed_message".tr(),
|
||||
@@ -395,16 +424,43 @@ class BackgroundService {
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
||||
String _formatAssetBackupProgress() {
|
||||
final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
|
||||
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {}
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
||||
_uploadedAssetsCount++;
|
||||
_updateNotification(
|
||||
progress: _uploadedAssetsCount,
|
||||
max: _assetsToUploadCount,
|
||||
content: _formatAssetBackupProgress(),
|
||||
);
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {
|
||||
final int now = Timeline.now;
|
||||
// limit updates to 10 per second (or Android drops important notifications)
|
||||
if (now > _lastDetailProgressUpdate + 100000) {
|
||||
final String msg = _humanReadableBytesProgress(sent, total);
|
||||
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
|
||||
if (msg != _lastPrintedProgress) {
|
||||
_lastDetailProgressUpdate = now;
|
||||
_lastPrintedProgress = msg;
|
||||
_updateNotification(
|
||||
progress: sent,
|
||||
max: total,
|
||||
isDetail: true,
|
||||
content: msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
_showErrorNotification(
|
||||
title: "Upload failed",
|
||||
content: "backup_background_service_upload_failure_notification"
|
||||
title: "backup_background_service_upload_failure_notification"
|
||||
.tr(args: [errorAssetInfo.fileName]),
|
||||
individualTag: errorAssetInfo.id,
|
||||
);
|
||||
@@ -412,14 +468,17 @@ class BackgroundService {
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
_updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: "backup_background_service_current_upload_notification"
|
||||
title: "backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]),
|
||||
content: "",
|
||||
isDetail: true,
|
||||
progress: 0,
|
||||
max: 0,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isErrorGracePeriodExceeded() {
|
||||
final int value = AppSettingsService()
|
||||
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
|
||||
final int value = appSettingsService
|
||||
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||
if (value == 0) {
|
||||
return true;
|
||||
@@ -444,9 +503,30 @@ class BackgroundService {
|
||||
assert(false, "Invalid value");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
|
||||
static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
|
||||
String unit = "KB"; // Kilobyte
|
||||
if (bytesTotal >= 0x40000000) {
|
||||
unit = "GB"; // Gigabyte
|
||||
bytes >>= 20;
|
||||
bytesTotal >>= 20;
|
||||
} else if (bytesTotal >= 0x100000) {
|
||||
unit = "MB"; // Megabyte
|
||||
bytes >>= 10;
|
||||
bytesTotal >>= 10;
|
||||
} else if (bytesTotal < 0x400) {
|
||||
return "$bytes / $bytesTotal B";
|
||||
}
|
||||
final int percent = (bytes * 100) ~/ bytesTotal;
|
||||
final String done = numberFormat.format(bytes / 1024.0);
|
||||
final String total = numberFormat.format(bytesTotal / 1024.0);
|
||||
return "$percent% ($done/$total$unit)";
|
||||
}
|
||||
}
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
@pragma('vm:entry-point')
|
||||
void _nativeEntry() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
BackgroundService backgroundService = BackgroundService();
|
||||
|
||||
@@ -26,7 +26,7 @@ class AvailableAlbum {
|
||||
|
||||
String get name => albumEntity.name;
|
||||
|
||||
int get assetCount => albumEntity.assetCount;
|
||||
Future<int> get assetCount => albumEntity.assetCountAsync;
|
||||
|
||||
String get id => albumEntity.id;
|
||||
|
||||
|
||||
@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
bool success = true;
|
||||
if (!wasEnabled) {
|
||||
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
||||
onBatteryInfo();
|
||||
}
|
||||
success &= await _backgroundService.enableService(immediate: true);
|
||||
}
|
||||
final bool success = await _backgroundService.stopService() &&
|
||||
await _backgroundService.startService(
|
||||
success &= success &&
|
||||
await _backgroundService.configureService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
);
|
||||
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await _backgroundService.stopService();
|
||||
final bool success = await _backgroundService.disableService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
@@ -181,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
var assetList =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assetCountInAlbum = await album.assetCountAsync;
|
||||
if (assetCountInAlbum > 0) {
|
||||
var assetList =
|
||||
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
|
||||
|
||||
if (assetList.isNotEmpty) {
|
||||
var thumbnailAsset = assetList.first;
|
||||
var thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
if (assetList.isNotEmpty) {
|
||||
var thumbnailAsset = assetList.first;
|
||||
var thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum =
|
||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
}
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
}
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
}
|
||||
|
||||
state = state.copyWith(availableAlbums: availableAlbums);
|
||||
@@ -294,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (var album in state.selectedBackupAlbums) {
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (var album in state.excludedBackupAlbums) {
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
@@ -351,11 +361,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
|
||||
await _getBackupAlbumsInfo();
|
||||
await _updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,9 @@ class BackupService {
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
||||
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
lastBackup[i] = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
albumInfo.assetCount.toString() +
|
||||
(albumInfo.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
child: FutureBuilder(
|
||||
builder: ((context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
snapshot.data.toString() +
|
||||
(albumInfo.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text("0");
|
||||
}),
|
||||
future: albumInfo.assetCount,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
).tr(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
OutlinedButton(
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse('https://dontkillmyapp.com'),
|
||||
mode: LaunchMode.externalApplication),
|
||||
child: Text(
|
||||
Uri.parse('https://dontkillmyapp.com'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
child: const Text(
|
||||
"backup_controller_page_background_battery_info_link",
|
||||
style: TextStyle(color: buttonTextColor),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
ElevatedButton(
|
||||
child: const Text(
|
||||
'backup_controller_page_background_battery_info_ok',
|
||||
style: TextStyle(color: buttonTextColor),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
).tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
@@ -508,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
DateTime.parse(
|
||||
backupState.currentUploadAsset.createdAt
|
||||
.toString(),
|
||||
),
|
||||
).toLocal(),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.red[300],
|
||||
onPrimary: Colors.grey[50],
|
||||
foregroundColor: Colors.grey[50],
|
||||
backgroundColor: Colors.red[300],
|
||||
// padding: const EdgeInsets.all(14),
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
||||
DateFormat.yMMMMd('en_US').format(
|
||||
DateTime.parse(
|
||||
errorAsset.createdAt.toString(),
|
||||
),
|
||||
).toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.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/shared/providers/asset.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
enum RenderAssetGridElementType {
|
||||
assetRow,
|
||||
dayTitle,
|
||||
monthTitle;
|
||||
}
|
||||
|
||||
class RenderAssetGridRow {
|
||||
final List<AssetResponseDto> assets;
|
||||
|
||||
RenderAssetGridRow(this.assets);
|
||||
}
|
||||
|
||||
class RenderAssetGridElement {
|
||||
final RenderAssetGridElementType type;
|
||||
final RenderAssetGridRow? assetRow;
|
||||
final String? title;
|
||||
final DateTime date;
|
||||
final List<AssetResponseDto>? relatedAssetList;
|
||||
|
||||
RenderAssetGridElement(
|
||||
this.type, {
|
||||
this.assetRow,
|
||||
this.title,
|
||||
required this.date,
|
||||
this.relatedAssetList,
|
||||
});
|
||||
}
|
||||
|
||||
final renderListProvider = StateProvider((ref) {
|
||||
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
|
||||
|
||||
List<RenderAssetGridElement> elements = [];
|
||||
DateTime? lastDate;
|
||||
|
||||
assetGroups.forEach((groupName, assets) {
|
||||
try {
|
||||
final date = DateTime.parse(groupName);
|
||||
|
||||
if (lastDate == null || lastDate!.month != date.month) {
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
title: groupName,
|
||||
date: date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add group title
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.dayTitle,
|
||||
title: groupName,
|
||||
date: date,
|
||||
relatedAssetList: assets,
|
||||
),
|
||||
);
|
||||
|
||||
// Add rows
|
||||
int cursor = 0;
|
||||
while (cursor < assets.length) {
|
||||
int rowElements = min(assets.length - cursor, assetsPerRow);
|
||||
|
||||
final rowElement = RenderAssetGridElement(
|
||||
RenderAssetGridElementType.assetRow,
|
||||
date: date,
|
||||
assetRow: RenderAssetGridRow(
|
||||
assets.sublist(cursor, cursor + rowElements),
|
||||
),
|
||||
);
|
||||
|
||||
elements.add(rowElement);
|
||||
cursor += rowElements;
|
||||
}
|
||||
|
||||
lastDate = date;
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
});
|
||||
107
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
Normal file
107
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class DailyTitleText extends ConsumerWidget {
|
||||
const DailyTitleText({
|
||||
Key? key,
|
||||
required this.isoDate,
|
||||
required this.assetGroup,
|
||||
}) : super(key: key);
|
||||
|
||||
final String isoDate;
|
||||
final List<AssetResponseDto> assetGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var currentYear = DateTime.now().year;
|
||||
var groupYear = DateTime.parse(isoDate).year;
|
||||
var formatDateTemplate = currentYear == groupYear
|
||||
? "daily_title_text_date".tr()
|
||||
: "daily_title_text_date_year".tr();
|
||||
var dateText =
|
||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
||||
var isMultiSelectEnable =
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
|
||||
|
||||
void _handleTitleIconClick() {
|
||||
if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length == 1 &&
|
||||
selectedItems.length <= assetGroup.length) {
|
||||
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
|
||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedItems.length != assetGroup.length) {
|
||||
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeSelectedDateGroup(dateText);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length > 1) {
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeSelectedDateGroup(dateText);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.addSelectedDateGroup(dateText);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.addMultipleSelectedItems(assetGroup);
|
||||
} else {
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.enableMultiSelect(assetGroup.toSet());
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.addSelectedDateGroup(dateText);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 29.0,
|
||||
bottom: 29.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
dateText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: _handleTitleIconClick,
|
||||
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
/// Build the Scroll Thumb and label using the current configuration
|
||||
typedef ScrollThumbBuilder = Widget Function(
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text? labelText,
|
||||
BoxConstraints? labelConstraints,
|
||||
});
|
||||
|
||||
/// Build a Text widget using the current scroll offset
|
||||
typedef LabelTextBuilder = Text Function(int item);
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
class DraggableScrollbar extends StatefulWidget {
|
||||
/// The view that will be scrolled with the scroll thumb
|
||||
final ScrollablePositionedList child;
|
||||
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
|
||||
/// A function that builds a thumb using the current configuration
|
||||
final ScrollThumbBuilder scrollThumbBuilder;
|
||||
|
||||
/// The height of the scroll thumb
|
||||
final double heightScrollThumb;
|
||||
|
||||
/// The background color of the label and thumb
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The amount of padding that should surround the thumb
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Determines how quickly the scrollbar will animate in and out
|
||||
final Duration scrollbarAnimationDuration;
|
||||
|
||||
/// How long should the thumb be visible before fading out
|
||||
final Duration scrollbarTimeToFade;
|
||||
|
||||
/// Build a Text widget from the current offset in the BoxScrollView
|
||||
final LabelTextBuilder? labelTextBuilder;
|
||||
|
||||
/// Determines box constraints for Container displaying label
|
||||
final BoxConstraints? labelConstraints;
|
||||
|
||||
/// The ScrollController for the BoxScrollView
|
||||
final ItemScrollController controller;
|
||||
|
||||
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
|
||||
final bool alwaysVisibleScrollThumb;
|
||||
|
||||
final Function(bool scrolling) scrollStateListener;
|
||||
|
||||
DraggableScrollbar.semicircle({
|
||||
Key? key,
|
||||
Key? scrollThumbKey,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.itemPositionsListener,
|
||||
required this.scrollStateListener,
|
||||
this.heightScrollThumb = 48.0,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder = _thumbSemicircleBuilder(
|
||||
heightScrollThumb * 0.6,
|
||||
scrollThumbKey,
|
||||
alwaysVisibleScrollThumb,
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||
|
||||
static buildScrollThumbAndLabel({
|
||||
required Widget scrollThumb,
|
||||
required Color backgroundColor,
|
||||
required Animation<double>? thumbAnimation,
|
||||
required Animation<double>? labelAnimation,
|
||||
required Text? labelText,
|
||||
required BoxConstraints? labelConstraints,
|
||||
required bool alwaysVisibleScrollThumb,
|
||||
}) {
|
||||
var scrollThumbAndLabel = labelText == null
|
||||
? scrollThumb
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ScrollLabel(
|
||||
animation: labelAnimation,
|
||||
backgroundColor: backgroundColor,
|
||||
constraints: labelConstraints,
|
||||
child: labelText,
|
||||
),
|
||||
scrollThumb,
|
||||
],
|
||||
);
|
||||
|
||||
if (alwaysVisibleScrollThumb) {
|
||||
return scrollThumbAndLabel;
|
||||
}
|
||||
return SlideFadeTransition(
|
||||
animation: thumbAnimation!,
|
||||
child: scrollThumbAndLabel,
|
||||
);
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbSemicircleBuilder(
|
||||
double width,
|
||||
Key? scrollThumbKey,
|
||||
bool alwaysVisibleScrollThumb,
|
||||
) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text? labelText,
|
||||
BoxConstraints? labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = CustomPaint(
|
||||
key: scrollThumbKey,
|
||||
foregroundPainter: ArrowCustomPainter(Colors.white),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(height),
|
||||
bottomLeft: Radius.circular(height),
|
||||
topRight: const Radius.circular(4.0),
|
||||
bottomRight: const Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(Size(width, height)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
scrollThumb: scrollThumb,
|
||||
backgroundColor: backgroundColor,
|
||||
thumbAnimation: thumbAnimation,
|
||||
labelAnimation: labelAnimation,
|
||||
labelText: labelText,
|
||||
labelConstraints: labelConstraints,
|
||||
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollLabel extends StatelessWidget {
|
||||
final Animation<double>? animation;
|
||||
final Color backgroundColor;
|
||||
final Text child;
|
||||
|
||||
final BoxConstraints? constraints;
|
||||
static const BoxConstraints _defaultConstraints =
|
||||
BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||
|
||||
const ScrollLabel({
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.animation,
|
||||
required this.backgroundColor,
|
||||
this.constraints = _defaultConstraints,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: animation!,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 12.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: Container(
|
||||
constraints: constraints ?? _defaultConstraints,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
with TickerProviderStateMixin {
|
||||
late double _barOffset;
|
||||
late bool _isDragInProcess;
|
||||
late int _currentItem;
|
||||
|
||||
late AnimationController _thumbAnimationController;
|
||||
late Animation<double> _thumbAnimation;
|
||||
late AnimationController _labelAnimationController;
|
||||
late Animation<double> _labelAnimation;
|
||||
Timer? _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_barOffset = 0.0;
|
||||
_isDragInProcess = false;
|
||||
_currentItem = 0;
|
||||
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get barMaxScrollExtent =>
|
||||
(context.size?.height ?? 0) - widget.heightScrollThumb;
|
||||
|
||||
double get barMinScrollExtent => 0;
|
||||
|
||||
int get maxItemCount => widget.child.itemCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Text? labelText;
|
||||
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||
labelText = widget.labelTextBuilder!(_currentItem);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
//print("LayoutBuilder constraints=$constraints");
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (ScrollNotification notification) {
|
||||
changePosition(notification);
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
RepaintBoundary(
|
||||
child: widget.child,
|
||||
),
|
||||
RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onVerticalDragStart,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
child: Container(
|
||||
alignment: Alignment.topRight,
|
||||
margin: EdgeInsets.only(top: _barOffset),
|
||||
padding: widget.padding,
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.heightScrollThumb,
|
||||
labelText: labelText,
|
||||
labelConstraints: widget.labelConstraints,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// scroll bar has received notification that it's view was scrolled
|
||||
// so it should also changes his position
|
||||
// but only if it isn't dragged
|
||||
changePosition(ScrollNotification notification) {
|
||||
if (_isDragInProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
int firstItemIndex =
|
||||
widget.itemPositionsListener.itemPositions.value.first.index;
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
|
||||
|
||||
if (_barOffset < barMinScrollExtent) {
|
||||
_barOffset = barMinScrollExtent;
|
||||
}
|
||||
if (_barOffset > barMaxScrollExtent) {
|
||||
_barOffset = barMaxScrollExtent;
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification ||
|
||||
notification is OverscrollNotification) {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
if (itemPos < maxItemCount) {
|
||||
_currentItem = itemPos;
|
||||
}
|
||||
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_isDragInProcess = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
});
|
||||
|
||||
widget.scrollStateListener(true);
|
||||
}
|
||||
|
||||
int get itemPos {
|
||||
int numberOfItems = widget.child.itemCount;
|
||||
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
|
||||
}
|
||||
|
||||
void _jumpToBarPos() {
|
||||
if (itemPos > maxItemCount - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentItem = itemPos;
|
||||
|
||||
widget.controller.jumpTo(
|
||||
index: itemPos,
|
||||
);
|
||||
}
|
||||
|
||||
Timer? dragHaltTimer;
|
||||
int lastTimerPos = 0;
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
if (_isDragInProcess) {
|
||||
_barOffset += details.delta.dy;
|
||||
|
||||
if (_barOffset < barMinScrollExtent) {
|
||||
_barOffset = barMinScrollExtent;
|
||||
}
|
||||
if (_barOffset > barMaxScrollExtent) {
|
||||
_barOffset = barMaxScrollExtent;
|
||||
}
|
||||
|
||||
if (itemPos != lastTimerPos) {
|
||||
lastTimerPos = itemPos;
|
||||
dragHaltTimer?.cancel();
|
||||
widget.scrollStateListener(true);
|
||||
|
||||
dragHaltTimer = Timer(
|
||||
const Duration(milliseconds: 200),
|
||||
() {
|
||||
widget.scrollStateListener(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_jumpToBarPos();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_jumpToBarPos();
|
||||
_isDragInProcess = false;
|
||||
});
|
||||
|
||||
widget.scrollStateListener(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws 2 triangles like arrow up and arrow down
|
||||
class ArrowCustomPainter extends CustomPainter {
|
||||
Color color;
|
||||
|
||||
ArrowCustomPainter(this.color);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
const width = 12.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||
paint,
|
||||
);
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(o.dx, o.dy)
|
||||
..lineTo(o.dx + width, o.dy)
|
||||
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
///This cut 2 lines in arrow shape
|
||||
class ArrowClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
Path path = Path();
|
||||
path.lineTo(0.0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(size.width, 0.0);
|
||||
path.lineTo(0.0, 0.0);
|
||||
path.close();
|
||||
|
||||
double arrowWidth = 8.0;
|
||||
double startPointX = (size.width - arrowWidth) / 2;
|
||||
double startPointY = size.height / 2 - arrowWidth / 2;
|
||||
path.moveTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2,
|
||||
startPointY - arrowWidth / 2 + 1.0,
|
||||
);
|
||||
path.lineTo(startPointX, startPointY + 1.0);
|
||||
path.close();
|
||||
|
||||
startPointY = size.height / 2 + arrowWidth / 2;
|
||||
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||
path.lineTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX, startPointY - 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2,
|
||||
startPointY + arrowWidth / 2 - 1.0,
|
||||
);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||
path.close();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
|
||||
class SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const SlideFadeTransition({
|
||||
Key? key,
|
||||
required this.animation,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) =>
|
||||
animation.value == 0.0 ? const SizedBox() : child!,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0.3, 0.0),
|
||||
end: const Offset(0.0, 0.0),
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
167
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
167
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/src/widgets/framework.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import '../thumbnail_image.dart';
|
||||
|
||||
class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
final List<RenderAssetGridElement> renderList;
|
||||
final int assetsPerRow;
|
||||
final double margin;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
ImmichAssetGrid({
|
||||
super.key,
|
||||
required this.renderList,
|
||||
required this.assetsPerRow,
|
||||
required this.showStorageIndicator,
|
||||
this.margin = 5.0,
|
||||
});
|
||||
|
||||
List<AssetResponseDto> get _assets {
|
||||
return renderList
|
||||
.map((e) {
|
||||
if (e.type == RenderAssetGridElementType.assetRow) {
|
||||
return e.assetRow!.assets;
|
||||
} else {
|
||||
return List<AssetResponseDto>.empty();
|
||||
}
|
||||
})
|
||||
.flattened
|
||||
.toList();
|
||||
}
|
||||
|
||||
double _getItemSize(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width / assetsPerRow -
|
||||
margin * (assetsPerRow - 1) / assetsPerRow;
|
||||
}
|
||||
|
||||
Widget _buildThumbnailOrPlaceholder(
|
||||
AssetResponseDto asset, bool placeholder) {
|
||||
if (placeholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: _assets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
useGrayBoxPlaceholder: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context, RenderAssetGridRow row, bool scrolling) {
|
||||
double size = _getItemSize(context);
|
||||
|
||||
return Row(
|
||||
key: Key("asset-row-${row.assets.first.id}"),
|
||||
children: row.assets.map((AssetResponseDto asset) {
|
||||
bool last = asset == row.assets.last;
|
||||
|
||||
return Container(
|
||||
key: Key("asset-${asset.id}"),
|
||||
width: size,
|
||||
height: size,
|
||||
margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
|
||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(
|
||||
BuildContext context, String title, List<AssetResponseDto> assets) {
|
||||
return DailyTitleText(
|
||||
isoDate: title,
|
||||
assetGroup: assets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||
.format(DateTime.parse(title));
|
||||
|
||||
return Padding(
|
||||
key: Key("month-$title"),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||
child: Text(
|
||||
monthTitleText,
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).textTheme.headline1?.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
|
||||
final item = renderList[position];
|
||||
|
||||
if (item.type == RenderAssetGridElementType.dayTitle) {
|
||||
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||
return _buildMonthTitle(c, item.title!);
|
||||
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||
return _buildAssetRow(c, item.assetRow!, scrolling);
|
||||
}
|
||||
|
||||
return const Text("Invalid widget type!");
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
final date = renderList[pos].date;
|
||||
return Text(DateFormat.yMMMd().format(date),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scrolling = useState(false);
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
scrolling.value = active;
|
||||
}
|
||||
|
||||
Widget itemBuilder(BuildContext c, int position) {
|
||||
return _itemBuilder(c, position, scrolling.value);
|
||||
}
|
||||
|
||||
return DraggableScrollbar.semicircle(
|
||||
scrollStateListener: dragScrolling,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
controller: _itemScrollController,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
labelTextBuilder: _labelBuilder,
|
||||
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemBuilder: itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemCount: renderList.length,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ class DailyTitleText extends ConsumerWidget {
|
||||
var formatDateTemplate = currentYear == groupYear
|
||||
? "daily_title_text_date".tr()
|
||||
: "daily_title_text_date_year".tr();
|
||||
var dateText =
|
||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
||||
var dateText = DateFormat(formatDateTemplate)
|
||||
.format(DateTime.parse(isoDate).toLocal());
|
||||
var isMultiSelectEnable =
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -10,13 +9,11 @@ class ImageGrid extends ConsumerWidget {
|
||||
final List<AssetResponseDto> sortedAssetGroup;
|
||||
final int tilesPerRow;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
ImageGrid({
|
||||
Key? key,
|
||||
required this.assetGroup,
|
||||
required this.sortedAssetGroup,
|
||||
this.cacheManager,
|
||||
this.tilesPerRow = 4,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
@@ -36,35 +33,10 @@ class ImageGrid extends ConsumerWidget {
|
||||
var assetType = assetGroup[index].type;
|
||||
return GestureDetector(
|
||||
onTap: () {},
|
||||
child: Stack(
|
||||
children: [
|
||||
ThumbnailImage(
|
||||
cacheManager: cacheManager,
|
||||
asset: assetGroup[index],
|
||||
assetList: sortedAssetGroup,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
),
|
||||
if (assetType != AssetTypeEnum.IMAGE)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
assetGroup[index].duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: ThumbnailImage(
|
||||
asset: assetGroup[index],
|
||||
assetList: sortedAssetGroup,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState backupState = ref.watch(backupProvider);
|
||||
bool isEnableAutoBackup =
|
||||
bool isEnableAutoBackup = backupState.backgroundBackup ||
|
||||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||
.format(DateTime.parse(isoDate));
|
||||
.format(DateTime.parse(isoDate).toLocal());
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
|
||||
@@ -2,8 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
@@ -17,14 +15,14 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
|
||||
const ThumbnailImage({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.cacheManager,
|
||||
this.showStorageIndicator = true,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -36,7 +34,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
|
||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
||||
Widget buildSelectionIcon(AssetResponseDto asset) {
|
||||
if (selectedAsset.contains(asset)) {
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
@@ -52,7 +50,6 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint("View ${asset.id}");
|
||||
if (isMultiSelectEnable &&
|
||||
selectedAsset.contains(asset) &&
|
||||
selectedAsset.length == 1) {
|
||||
@@ -95,26 +92,35 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
: const Border(),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: asset.id,
|
||||
cacheManager: cacheManager,
|
||||
cacheKey: 'thumbnail-image-${asset.id}',
|
||||
width: 300,
|
||||
height: 300,
|
||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
|
||||
memCacheHeight: 200,
|
||||
maxWidthDiskCache: 200,
|
||||
maxHeightDiskCache: 200,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
||||
Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(
|
||||
value: downloadProgress.progress,
|
||||
),
|
||||
),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) {
|
||||
if (useGrayBoxPlaceholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
return Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(
|
||||
value: downloadProgress.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) {
|
||||
debugPrint("Error getting thumbnail $url = $error");
|
||||
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
|
||||
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
@@ -127,7 +133,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _buildSelectionIcon(asset),
|
||||
child: buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator)
|
||||
@@ -141,7 +147,27 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
)
|
||||
),
|
||||
if (asset.type != AssetTypeEnum.IMAGE)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||
@@ -16,7 +18,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
|
||||
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/cache.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
@@ -25,7 +26,8 @@ class HomePage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
var renderList = ref.watch(renderListProvider);
|
||||
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||
@@ -91,7 +93,6 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
imageGridGroup.add(
|
||||
ImageGrid(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
assetGroup: immichAssetList,
|
||||
sortedAssetGroup: sortedAssetList,
|
||||
tilesPerRow:
|
||||
@@ -123,6 +124,31 @@ class HomePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_buildAssetGrid() {
|
||||
if (appSettingService
|
||||
.getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
|
||||
return ImmichAssetGrid(
|
||||
renderList: renderList,
|
||||
assetsPerRow:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
);
|
||||
} else {
|
||||
return DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
...imageGridGroup,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
bottom: !isMultiSelectEnable,
|
||||
top: !isMultiSelectEnable,
|
||||
@@ -135,17 +161,7 @@ class HomePage extends HookConsumerWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
...imageGridGroup,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _buildAssetGrid(),
|
||||
),
|
||||
if (isMultiSelectEnable) ...[
|
||||
_buildSelectedItemCountIndicator(),
|
||||
|
||||
@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
|
||||
(a, b) => b.compareTo(a),
|
||||
);
|
||||
return assets.groupListsBy(
|
||||
(element) =>
|
||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
||||
(element) => DateFormat('y-MM-dd')
|
||||
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
_buildThings() {
|
||||
return curatedObjects.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: ImmichLoadingIndicator()),
|
||||
loading: () => SizedBox(
|
||||
height: imageSize,
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (objects) {
|
||||
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
// height: imageSize,
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
||||
@@ -6,11 +6,16 @@ enum AppSettingsEnum<T> {
|
||||
themeMode<String>("themeMode", "system"), // "light","dark","system"
|
||||
tilesPerRow<int>("tilesPerRow", 4),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
"uploadErrorNotificationGracePeriod", 2),
|
||||
"uploadErrorNotificationGracePeriod",
|
||||
2,
|
||||
),
|
||||
backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
|
||||
backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
|
||||
storageIndicator<bool>("storageIndicator", true),
|
||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>("imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200);
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
|
||||
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
|
||||
|
||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ class CacheSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
final clearCacheState = useState(false);
|
||||
|
||||
Future<void> clearCache() async {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class ExperimentalSettings extends HookConsumerWidget {
|
||||
const ExperimentalSettings({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final useExperimentalAssetGrid = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
useExperimentalAssetGrid.value = appSettingService
|
||||
.getSetting(AppSettingsEnum.useExperimentalAssetGrid);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void changeUseExperimentalAssetGrid(bool status) {
|
||||
useExperimentalAssetGrid.value = status;
|
||||
appSettingService.setSetting(
|
||||
AppSettingsEnum.useExperimentalAssetGrid,
|
||||
status,
|
||||
);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "settings_require_restart".tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
return ExpansionTile(
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'experimental_settings_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'experimental_settings_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
"experimental_settings_new_asset_list_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
"experimental_settings_new_asset_list_subtitle",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
value: useExperimentalAssetGrid.value,
|
||||
onChanged: changeUseExperimentalAssetGrid,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,20 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final sliderValue = useState(0.0);
|
||||
final totalProgressValue =
|
||||
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
|
||||
final singleProgressValue =
|
||||
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
sliderValue.value = appSettingService
|
||||
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
|
||||
.toDouble();
|
||||
totalProgressValue.value = appSettingService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||
singleProgressValue.value = appSettingService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
@@ -42,6 +50,22 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
),
|
||||
).tr(),
|
||||
children: [
|
||||
_buildSwitchListTile(
|
||||
context,
|
||||
appSettingService,
|
||||
totalProgressValue,
|
||||
AppSettingsEnum.backgroundBackupTotalProgress,
|
||||
title: 'setting_notifications_total_progress_title'.tr(),
|
||||
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
|
||||
),
|
||||
_buildSwitchListTile(
|
||||
context,
|
||||
appSettingService,
|
||||
singleProgressValue,
|
||||
AppSettingsEnum.backgroundBackupSingleProgress,
|
||||
title: 'setting_notifications_single_progress_title'.tr(),
|
||||
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
|
||||
),
|
||||
ListTile(
|
||||
isThreeLine: false,
|
||||
dense: true,
|
||||
@@ -53,7 +77,9 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
value: sliderValue.value,
|
||||
onChanged: (double v) => sliderValue.value = v,
|
||||
onChangeEnd: (double v) => appSettingService.setSetting(
|
||||
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
|
||||
AppSettingsEnum.uploadErrorNotificationGracePeriod,
|
||||
v.toInt(),
|
||||
),
|
||||
max: 5.0,
|
||||
divisions: 5,
|
||||
label: formattedValue,
|
||||
@@ -65,6 +91,28 @@ 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();
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||
@@ -42,8 +42,8 @@ class SettingsPage extends HookConsumerWidget {
|
||||
const ImageViewerQualitySetting(),
|
||||
const ThemeSetting(),
|
||||
const AssetListSettings(),
|
||||
const CacheSettings(),
|
||||
if (Platform.isAndroid) const NotificationSetting(),
|
||||
const ExperimentalSettings(),
|
||||
],
|
||||
).toList(),
|
||||
],
|
||||
|
||||
@@ -59,8 +59,6 @@ class _$AppRouter extends RootStackRouter {
|
||||
authToken: args.authToken,
|
||||
isZoomedFunction: args.isZoomedFunction,
|
||||
isZoomedListener: args.isZoomedListener,
|
||||
onLoadingCompleted: args.onLoadingCompleted,
|
||||
onLoadingStart: args.onLoadingStart,
|
||||
threeStageLoading: args.threeStageLoading));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
@@ -297,8 +295,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
required String authToken,
|
||||
required void Function() isZoomedFunction,
|
||||
required ValueNotifier<bool> isZoomedListener,
|
||||
required void Function() onLoadingCompleted,
|
||||
required void Function() onLoadingStart,
|
||||
required bool threeStageLoading})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
@@ -309,8 +305,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
threeStageLoading: threeStageLoading));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
@@ -324,8 +318,6 @@ class ImageViewerRouteArgs {
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
required this.threeStageLoading});
|
||||
|
||||
final Key? key;
|
||||
@@ -340,15 +332,11 @@ class ImageViewerRouteArgs {
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
final void Function() onLoadingCompleted;
|
||||
|
||||
final void Function() onLoadingStart;
|
||||
|
||||
final bool threeStageLoading;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}';
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, threeStageLoading: $threeStageLoading}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
|
||||
(a, b) => b.compareTo(a),
|
||||
);
|
||||
return assets.groupListsBy(
|
||||
(element) =>
|
||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
||||
(element) => DateFormat('y-MM-dd')
|
||||
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
|
||||
);
|
||||
|
||||
return assets.groupListsBy(
|
||||
(element) =>
|
||||
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)),
|
||||
(element) => DateFormat('MMMM, y')
|
||||
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
|
||||
enum CacheType {
|
||||
// Shared cache for asset thumbnails in various modules
|
||||
thumbnail,
|
||||
|
||||
imageViewerPreview,
|
||||
imageViewerFull,
|
||||
albumThumbnail,
|
||||
@@ -67,7 +66,10 @@ class CacheService {
|
||||
}
|
||||
|
||||
BaseCacheManager _getDefaultCache(
|
||||
String cacheName, int size, CacheInfoRepository repo) {
|
||||
String cacheName,
|
||||
int size,
|
||||
CacheInfoRepository repo,
|
||||
) {
|
||||
return CacheManager(
|
||||
Config(
|
||||
cacheName,
|
||||
|
||||
@@ -10,6 +10,7 @@ class ImmichToast {
|
||||
ToastType toastType = ToastType.info,
|
||||
ToastGravity gravity = ToastGravity.TOP,
|
||||
}) {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final fToast = FToast();
|
||||
fToast.init(context);
|
||||
|
||||
@@ -49,7 +50,7 @@ class ImmichToast {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
color: Colors.grey[50],
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
border: Border.all(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
|
||||
@@ -6,10 +6,13 @@ doc/AddAssetsDto.md
|
||||
doc/AddUsersDto.md
|
||||
doc/AdminSignupResponseDto.md
|
||||
doc/AlbumApi.md
|
||||
doc/AlbumCountResponseDto.md
|
||||
doc/AlbumResponseDto.md
|
||||
doc/AllJobStatusResponseDto.md
|
||||
doc/AssetApi.md
|
||||
doc/AssetCountByTimeBucket.md
|
||||
doc/AssetCountByTimeBucketResponseDto.md
|
||||
doc/AssetCountByUserIdResponseDto.md
|
||||
doc/AssetFileUploadResponseDto.md
|
||||
doc/AssetResponseDto.md
|
||||
doc/AssetTypeEnum.md
|
||||
@@ -31,6 +34,12 @@ doc/DeviceTypeEnum.md
|
||||
doc/ExifResponseDto.md
|
||||
doc/GetAssetByTimeBucketDto.md
|
||||
doc/GetAssetCountByTimeBucketDto.md
|
||||
doc/JobApi.md
|
||||
doc/JobCommand.md
|
||||
doc/JobCommandDto.md
|
||||
doc/JobCounts.md
|
||||
doc/JobId.md
|
||||
doc/JobStatusResponseDto.md
|
||||
doc/LoginCredentialDto.md
|
||||
doc/LoginResponseDto.md
|
||||
doc/LogoutResponseDto.md
|
||||
@@ -57,6 +66,7 @@ lib/api/album_api.dart
|
||||
lib/api/asset_api.dart
|
||||
lib/api/authentication_api.dart
|
||||
lib/api/device_info_api.dart
|
||||
lib/api/job_api.dart
|
||||
lib/api/server_info_api.dart
|
||||
lib/api/user_api.dart
|
||||
lib/api_client.dart
|
||||
@@ -70,9 +80,12 @@ lib/auth/oauth.dart
|
||||
lib/model/add_assets_dto.dart
|
||||
lib/model/add_users_dto.dart
|
||||
lib/model/admin_signup_response_dto.dart
|
||||
lib/model/album_count_response_dto.dart
|
||||
lib/model/album_response_dto.dart
|
||||
lib/model/all_job_status_response_dto.dart
|
||||
lib/model/asset_count_by_time_bucket.dart
|
||||
lib/model/asset_count_by_time_bucket_response_dto.dart
|
||||
lib/model/asset_count_by_user_id_response_dto.dart
|
||||
lib/model/asset_file_upload_response_dto.dart
|
||||
lib/model/asset_response_dto.dart
|
||||
lib/model/asset_type_enum.dart
|
||||
@@ -92,6 +105,11 @@ lib/model/device_type_enum.dart
|
||||
lib/model/exif_response_dto.dart
|
||||
lib/model/get_asset_by_time_bucket_dto.dart
|
||||
lib/model/get_asset_count_by_time_bucket_dto.dart
|
||||
lib/model/job_command.dart
|
||||
lib/model/job_command_dto.dart
|
||||
lib/model/job_counts.dart
|
||||
lib/model/job_id.dart
|
||||
lib/model/job_status_response_dto.dart
|
||||
lib/model/login_credential_dto.dart
|
||||
lib/model/login_response_dto.dart
|
||||
lib/model/logout_response_dto.dart
|
||||
|
||||
@@ -69,6 +69,7 @@ Class | Method | HTTP request | Description
|
||||
*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
|
||||
*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album |
|
||||
*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
|
||||
*AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
|
||||
*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
|
||||
*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album |
|
||||
*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
|
||||
@@ -81,6 +82,7 @@ Class | Method | HTTP request | Description
|
||||
*AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
|
||||
*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
||||
*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
||||
*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
|
||||
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||
*AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
|
||||
*AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
||||
@@ -95,6 +97,9 @@ 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 |
|
||||
*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} |
|
||||
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
|
||||
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
|
||||
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
|
||||
@@ -113,9 +118,12 @@ Class | Method | HTTP request | Description
|
||||
- [AddAssetsDto](doc//AddAssetsDto.md)
|
||||
- [AddUsersDto](doc//AddUsersDto.md)
|
||||
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
|
||||
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
|
||||
- [AlbumResponseDto](doc//AlbumResponseDto.md)
|
||||
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
||||
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
|
||||
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
|
||||
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
|
||||
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
||||
- [AssetResponseDto](doc//AssetResponseDto.md)
|
||||
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
||||
@@ -135,6 +143,11 @@ Class | Method | HTTP request | Description
|
||||
- [ExifResponseDto](doc//ExifResponseDto.md)
|
||||
- [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
|
||||
- [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
|
||||
- [JobCommand](doc//JobCommand.md)
|
||||
- [JobCommandDto](doc//JobCommandDto.md)
|
||||
- [JobCounts](doc//JobCounts.md)
|
||||
- [JobId](doc//JobId.md)
|
||||
- [JobStatusResponseDto](doc//JobStatusResponseDto.md)
|
||||
- [LoginCredentialDto](doc//LoginCredentialDto.md)
|
||||
- [LoginResponseDto](doc//LoginResponseDto.md)
|
||||
- [LogoutResponseDto](doc//LogoutResponseDto.md)
|
||||
|
||||
@@ -13,6 +13,7 @@ Method | HTTP request | Description
|
||||
[**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
|
||||
[**createAlbum**](AlbumApi.md#createalbum) | **POST** /album |
|
||||
[**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
|
||||
[**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
|
||||
[**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
|
||||
[**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album |
|
||||
[**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
|
||||
@@ -211,6 +212,49 @@ void (empty response body)
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAlbumCountByUserId**
|
||||
> AlbumCountResponseDto getAlbumCountByUserId()
|
||||
|
||||
|
||||
|
||||
### 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 = AlbumApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.getAlbumCountByUserId();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AlbumApi->getAlbumCountByUserId: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**AlbumCountResponseDto**](AlbumCountResponseDto.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)
|
||||
|
||||
# **getAlbumInfo**
|
||||
> AlbumResponseDto getAlbumInfo(albumId)
|
||||
|
||||
|
||||
17
mobile/openapi/doc/AlbumCountResponseDto.md
Normal file
17
mobile/openapi/doc/AlbumCountResponseDto.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# openapi.model.AlbumCountResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**owned** | **int** | |
|
||||
**shared** | **int** | |
|
||||
**sharing** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
22
mobile/openapi/doc/AllJobStatusResponseDto.md
Normal file
22
mobile/openapi/doc/AllJobStatusResponseDto.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# openapi.model.AllJobStatusResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**thumbnailGenerationQueueCount** | [**JobCounts**](JobCounts.md) | |
|
||||
**metadataExtractionQueueCount** | [**JobCounts**](JobCounts.md) | |
|
||||
**videoConversionQueueCount** | [**JobCounts**](JobCounts.md) | |
|
||||
**machineLearningQueueCount** | [**JobCounts**](JobCounts.md) | |
|
||||
**isThumbnailGenerationActive** | **bool** | |
|
||||
**isMetadataExtractionActive** | **bool** | |
|
||||
**isVideoConversionActive** | **bool** | |
|
||||
**isMachineLearningActive** | **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)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Method | HTTP request | Description
|
||||
[**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
|
||||
[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
||||
[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
||||
[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
|
||||
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||
[**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
|
||||
[**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
||||
@@ -363,6 +364,49 @@ 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)
|
||||
|
||||
# **getAssetCountByUserId**
|
||||
> AssetCountByUserIdResponseDto getAssetCountByUserId()
|
||||
|
||||
|
||||
|
||||
### 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 = AssetApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.getAssetCountByUserId();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getAssetCountByUserId: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.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)
|
||||
|
||||
# **getAssetSearchTerms**
|
||||
> List<String> getAssetSearchTerms()
|
||||
|
||||
|
||||
16
mobile/openapi/doc/AssetCountByUserIdResponseDto.md
Normal file
16
mobile/openapi/doc/AssetCountByUserIdResponseDto.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# openapi.model.AssetCountByUserIdResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**photos** | **int** | |
|
||||
**videos** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
15
mobile/openapi/doc/CreateJobDto.md
Normal file
15
mobile/openapi/doc/CreateJobDto.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# openapi.model.CreateJobDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**jobType** | [**JobType**](JobType.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)
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**id** | **String** | | [optional]
|
||||
**id** | **int** | | [optional]
|
||||
**fileSizeInByte** | **int** | | [optional]
|
||||
**make** | **String** | | [optional]
|
||||
**model** | **String** | | [optional]
|
||||
**imageName** | **String** | | [optional]
|
||||
**exifImageWidth** | **num** | | [optional]
|
||||
**exifImageHeight** | **num** | | [optional]
|
||||
**fileSizeInByte** | **num** | | [optional]
|
||||
**orientation** | **String** | | [optional]
|
||||
**dateTimeOriginal** | [**DateTime**](DateTime.md) | | [optional]
|
||||
**modifyDate** | [**DateTime**](DateTime.md) | | [optional]
|
||||
|
||||
155
mobile/openapi/doc/JobApi.md
Normal file
155
mobile/openapi/doc/JobApi.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# openapi.api.JobApi
|
||||
|
||||
## Load the API package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**getAllJobsStatus**](JobApi.md#getalljobsstatus) | **GET** /jobs |
|
||||
[**getJobStatus**](JobApi.md#getjobstatus) | **GET** /jobs/{jobId} |
|
||||
[**sendJobCommand**](JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
|
||||
|
||||
|
||||
# **getAllJobsStatus**
|
||||
> AllJobStatusResponseDto getAllJobsStatus()
|
||||
|
||||
|
||||
|
||||
### 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 = JobApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.getAllJobsStatus();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling JobApi->getAllJobsStatus: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**AllJobStatusResponseDto**](AllJobStatusResponseDto.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)
|
||||
|
||||
# **getJobStatus**
|
||||
> JobStatusResponseDto getJobStatus(jobId)
|
||||
|
||||
|
||||
|
||||
### 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 = JobApi();
|
||||
final jobId = ; // JobId |
|
||||
|
||||
try {
|
||||
final result = api_instance.getJobStatus(jobId);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling JobApi->getJobStatus: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**jobId** | [**JobId**](.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**JobStatusResponseDto**](JobStatusResponseDto.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)
|
||||
|
||||
# **sendJobCommand**
|
||||
> num sendJobCommand(jobId, jobCommandDto)
|
||||
|
||||
|
||||
|
||||
### 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 = JobApi();
|
||||
final jobId = ; // JobId |
|
||||
final jobCommandDto = JobCommandDto(); // JobCommandDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.sendJobCommand(jobId, jobCommandDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling JobApi->sendJobCommand: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**jobId** | [**JobId**](.md)| |
|
||||
**jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
**num**
|
||||
|
||||
### 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)
|
||||
|
||||
14
mobile/openapi/doc/JobCommand.md
Normal file
14
mobile/openapi/doc/JobCommand.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# openapi.model.JobCommand
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
15
mobile/openapi/doc/JobCommandDto.md
Normal file
15
mobile/openapi/doc/JobCommandDto.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# openapi.model.JobCommandDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**command** | [**JobCommand**](JobCommand.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)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user