Compare commits
145 Commits
v1.25.0_35
...
v1.30.2_48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
de0c59efe7 | ||
|
|
c19d26f4f3 | ||
|
|
2edfc75c8a | ||
|
|
4c977d2c1f | ||
|
|
1425f2ec78 | ||
|
|
b081eda76f | ||
|
|
7f6837c751 | ||
|
|
a467936e73 | ||
|
|
2677ddccaa | ||
|
|
564ace3ddf | ||
|
|
a81ef7497c | ||
|
|
caa7b07398 | ||
|
|
6976a7241e | ||
|
|
172eda3ce5 | ||
|
|
552340add7 | ||
|
|
bd92dde117 | ||
|
|
617c54ab81 | ||
|
|
c76f7804ab | ||
|
|
0799aa2c72 | ||
|
|
b80dca74ef | ||
|
|
f5f00e0f6c | ||
|
|
75d2d82d05 | ||
|
|
5172242f88 | ||
|
|
25e68cf826 | ||
|
|
e527685ebf | ||
|
|
e745cb5e4b | ||
|
|
dfaa4969da | ||
|
|
f980a2f27a | ||
|
|
6b7c97c02a | ||
|
|
fdd9f37abd | ||
|
|
a09bba454c | ||
|
|
4be9aa091b | ||
|
|
33b810de74 | ||
|
|
44ccb1eec1 | ||
|
|
bef38c670c | ||
|
|
025d7bf192 | ||
|
|
5ad2d62039 | ||
|
|
a128833e68 | ||
|
|
87f7b0849a | ||
|
|
4596a8ee01 | ||
|
|
f9b1b12b10 | ||
|
|
68b1655e7f | ||
|
|
658b64df74 | ||
|
|
e344503834 | ||
|
|
bf2760ffef | ||
|
|
db2ed2d881 | ||
|
|
fb0fa742f5 | ||
|
|
3b55cdc0be | ||
|
|
0efcc99f3e | ||
|
|
7a85164a1e | ||
|
|
ba2cda8955 | ||
|
|
9048be4c8e |
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}}"
|
||||||
21
.github/workflows/test.yml
vendored
21
.github/workflows/test.yml
vendored
@@ -13,18 +13,29 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Run Immich Server 2E2 Test
|
- name: Run Immich Server 2E2 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
|
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:
|
server-unit-tests:
|
||||||
name: Run unit test suites
|
name: Run server unit test suites and checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Run tests
|
- 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
|
||||||
|
|||||||
134
CODE_OF_CONDUCT.md
Normal file
134
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation
|
||||||
|
in our community a harassment-free experience for everyone, regardless
|
||||||
|
of age, body size, visible or invisible disability, ethnicity, sex
|
||||||
|
characteristics, gender identity and expression, level of experience,
|
||||||
|
education, socio-economic status, nationality, personal appearance,
|
||||||
|
race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open,
|
||||||
|
welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for
|
||||||
|
our community include:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our
|
||||||
|
mistakes, and learning from the experience
|
||||||
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or
|
||||||
|
political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in
|
||||||
|
a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our
|
||||||
|
standards of acceptable behavior and will take appropriate and fair
|
||||||
|
corrective action in response to any behavior that they deem
|
||||||
|
inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit,
|
||||||
|
or reject comments, commits, code, wiki edits, issues, and other
|
||||||
|
contributions that are not aligned to this Code of Conduct, and will
|
||||||
|
communicate reasons for moderation decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also
|
||||||
|
applies when an individual is officially representing the community in
|
||||||
|
public spaces. Examples of representing our community include using an
|
||||||
|
official e-mail address, posting via an official social media account,
|
||||||
|
or acting as an appointed representative at an online or offline
|
||||||
|
event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||||
|
may be reported to the community leaders responsible for enforcement
|
||||||
|
at our Discord channel. All complaints
|
||||||
|
will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and
|
||||||
|
security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in
|
||||||
|
determining the consequences for any action they deem in violation of
|
||||||
|
this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior
|
||||||
|
deemed unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders,
|
||||||
|
providing clarity around the nature of the violation and an
|
||||||
|
explanation of why the behavior was inappropriate. A public apology
|
||||||
|
may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued
|
||||||
|
behavior. No interaction with the people involved, including
|
||||||
|
unsolicited interaction with those enforcing the Code of Conduct, for
|
||||||
|
a specified period of time. This includes avoiding interactions in
|
||||||
|
community spaces as well as external channels like social
|
||||||
|
media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards,
|
||||||
|
including sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or
|
||||||
|
public communication with the community for a specified period of
|
||||||
|
time. No public or private interaction with the people involved,
|
||||||
|
including unsolicited interaction with those enforcing the Code of
|
||||||
|
Conduct, is allowed during this period. Violating these terms may lead
|
||||||
|
to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of
|
||||||
|
community standards, including sustained inappropriate behavior,
|
||||||
|
harassment of an individual, or aggression toward or disparagement of
|
||||||
|
classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction
|
||||||
|
within the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor
|
||||||
|
Covenant][homepage], version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of
|
||||||
|
conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the
|
||||||
|
FAQ at https://www.contributor-covenant.org/faq. Translations are
|
||||||
|
available at https://www.contributor-covenant.org/translations.
|
||||||
72
README.md
72
README.md
@@ -23,11 +23,30 @@
|
|||||||
<br/>
|
<br/>
|
||||||
</p>
|
</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
|
## Content
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Screenshots](#screenshots)
|
- [Screenshots](#screenshots)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Mobile App](#-mobile-app)
|
- [Update](#update)
|
||||||
|
- [Mobile App](#mobile-app)
|
||||||
|
- [App Beta Invitation links](#App-Beta-release-channel)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Support](#support)
|
- [Support](#support)
|
||||||
- [Known Issues](#known-issues)
|
- [Known Issues](#known-issues)
|
||||||
@@ -36,20 +55,22 @@
|
|||||||
|
|
||||||
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
|
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
|
||||||
|
|
||||||
| | Mobile | Web |
|
| Features | Mobile | Web |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
| ☁️ Upload and view videos and photos | Yes | Yes
|
| Upload and view videos and photos | Yes | Yes
|
||||||
| 🔄 Auto backup when the app is opened | Yes | N/A
|
| Auto backup when the app is opened | Yes | N/A
|
||||||
| ☑️ Selective album(s) for backup | Yes | N/A
|
| Selective album(s) for backup | Yes | N/A
|
||||||
| ⬇️ Download photos and videos to local device | Yes | Yes
|
| Download photos and videos to local device | Yes | Yes
|
||||||
| 👪 Multi-user support | Yes | Yes
|
| Multi-user support | Yes | Yes
|
||||||
| 🖼️ Album | Yes | Yes
|
| Album | Yes | Yes
|
||||||
| 🤝 Shared Albums | Yes | Yes
|
| Shared Albums | Yes | Yes
|
||||||
| 🚀 Quick navigation with draggable scrollbar | Yes | Yes
|
| Quick navigation with draggable scrollbar | Yes | Yes
|
||||||
| 🗃️ Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
|
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
|
||||||
| 🧭 Metadata view (EXIF, map) | Yes | Yes
|
| Metadata view (EXIF, map) | Yes | Yes
|
||||||
| 🔎 Search by metadata, objects and image tags | Yes | No
|
| Search by metadata, objects and image tags | Yes | No
|
||||||
| ⚙️ Administrative functions (user management) | N/A | Yes
|
| Administrative functions (user management) | N/A | Yes
|
||||||
|
| Background backup | Android | N/A
|
||||||
|
| Virtual scroll | N/A | Yes
|
||||||
|
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
@@ -95,6 +116,8 @@ There are several services that compose Immich:
|
|||||||
|
|
||||||
# Installation
|
# 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)
|
## Testing One-step installation (not recommended for production)
|
||||||
|
|
||||||
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
||||||
@@ -141,7 +164,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
|||||||
* Populate custom database information if necessary.
|
* Populate custom database information if necessary.
|
||||||
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
* 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`
|
* 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
|
### Step 3 - Start the containers
|
||||||
|
|
||||||
@@ -168,15 +190,27 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
|||||||
|
|
||||||
<br/>
|
<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
|
# Mobile app
|
||||||
|
|
||||||
| F-Droid | Google Play | iOS |
|
| 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/>
|
<br/>
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
@@ -223,7 +257,7 @@ Cheers! 🎉
|
|||||||
|
|
||||||
## TensorFlow Build Issue
|
## 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`:
|
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`:
|
||||||
|
|
||||||
@@ -231,7 +265,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
|
|||||||
more /proc/cpuinfo | grep flags
|
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.
|
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:
|
# Optional Database settings:
|
||||||
# DB_PORT=5432
|
# DB_PORT=5432
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Redis
|
# Redis
|
||||||
###################################################################################
|
###################################################################################
|
||||||
@@ -25,18 +22,17 @@ REDIS_HOSTNAME=immich_redis
|
|||||||
# REDIS_PASSWORD=
|
# REDIS_PASSWORD=
|
||||||
# REDIS_SOCKET=
|
# REDIS_SOCKET=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Upload File Config
|
# Upload File Config
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||||
|
|
||||||
|
###################################################################################
|
||||||
|
# Log message level - [simple|verbose]
|
||||||
|
###################################################################################
|
||||||
|
|
||||||
|
LOG_LEVEL=simple
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# JWT SECRET
|
# JWT SECRET
|
||||||
@@ -44,23 +40,27 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
|
|||||||
|
|
||||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# MAPBOX
|
# Reverse Geocoding
|
||||||
####################################################################################
|
####################################################################################
|
||||||
|
|
||||||
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
# DISABLE_REVERSE_GEOCODING=false
|
||||||
ENABLE_MAPBOX=false
|
|
||||||
MAPBOX_KEY=
|
|
||||||
|
|
||||||
|
# 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
|
# WEB - Optional
|
||||||
####################################################################################
|
####################################################################################
|
||||||
|
|
||||||
# Custom message on the login page, should be written in HTML form.
|
# Custom message on the login page, should be written in HTML form.
|
||||||
# For example VITE_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
# 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>"
|
||||||
|
|
||||||
VITE_LOGIN_PAGE_MESSAGE=
|
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
|
|||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
MAPBOX_KEY=
|
MAPBOX_KEY=
|
||||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run start:dev immich
|
command: npm run start:dev immich
|
||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app
|
- ../server:/usr/src/app
|
||||||
@@ -24,6 +25,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../machine-learning
|
context: ../machine-learning
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run start:dev
|
command: npm run start:dev
|
||||||
volumes:
|
volumes:
|
||||||
- ../machine-learning:/usr/src/app
|
- ../machine-learning:/usr/src/app
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: builder
|
||||||
command: npm run start:dev microservices
|
command: npm run start:dev microservices
|
||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app
|
- ../server:/usr/src/app
|
||||||
@@ -99,8 +102,7 @@ services:
|
|||||||
context: ../nginx
|
context: ../nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:8080
|
||||||
- 2284:443
|
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -72,8 +72,7 @@ services:
|
|||||||
container_name: immich_proxy
|
container_name: immich_proxy
|
||||||
image: altran1502/immich-proxy:staging
|
image: altran1502/immich-proxy:staging
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:8080
|
||||||
- 2284:443
|
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
container_name: immich_proxy
|
container_name: immich_proxy
|
||||||
image: altran1502/immich-proxy:release
|
image: altran1502/immich-proxy:release
|
||||||
ports:
|
ports:
|
||||||
- 2283:80
|
- 2283:8080
|
||||||
logging:
|
logging:
|
||||||
driver: none
|
driver: none
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
36
install.sh
36
install.sh
@@ -2,12 +2,17 @@ echo "Starting Immich installation..."
|
|||||||
|
|
||||||
ip_address=$(hostname -I | awk '{print $1}')
|
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'
|
RED='\033[0;31m'
|
||||||
GREEN='\032[0;31m'
|
GREEN='\032[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
machine_has() {
|
get_release_version() {
|
||||||
type "$1" >/dev/null 2>&1
|
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() {
|
create_immich_directory() {
|
||||||
@@ -17,12 +22,12 @@ create_immich_directory() {
|
|||||||
|
|
||||||
download_docker_compose_file() {
|
download_docker_compose_file() {
|
||||||
echo "Downloading docker-compose.yml..."
|
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() {
|
download_dot_env_file() {
|
||||||
echo "Downloading .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() {
|
populate_upload_location() {
|
||||||
@@ -45,18 +50,21 @@ populate_upload_location() {
|
|||||||
start_docker_compose() {
|
start_docker_compose() {
|
||||||
echo "Starting Immich's docker containers"
|
echo "Starting Immich's docker containers"
|
||||||
|
|
||||||
if machine_has "docker compose"; then {
|
if docker compose &>/dev/null; then
|
||||||
docker compose up --remove-orphans -d
|
docker_bin="docker compose"
|
||||||
|
elif docker-compose &>/dev/null; then
|
||||||
show_friendly_message
|
docker_bin="docker-compose"
|
||||||
exit 0
|
else
|
||||||
}; fi
|
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||||
|
exit 1
|
||||||
if machine_has "docker-compose"; then
|
fi
|
||||||
docker-compose up --remove-orphans -d
|
|
||||||
|
|
||||||
|
if $docker_bin up --remove-orphans -d; then
|
||||||
show_friendly_message
|
show_friendly_message
|
||||||
exit 0
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Could not start. Check for errors above."
|
||||||
|
exit 1
|
||||||
fi
|
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 "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 "The backup (or upload) location is $upload_location"
|
||||||
echo "---------------------------------------------------"
|
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,
|
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ upload:
|
|||||||
locale_code: de-DE
|
locale_code: de-DE
|
||||||
- file: mobile/assets/i18n/fr-FR.json
|
- file: mobile/assets/i18n/fr-FR.json
|
||||||
locale_code: fr-FR
|
locale_code: fr-FR
|
||||||
|
- file: mobile/assets/i18n/it-IT.json
|
||||||
|
locale_code: it-IT
|
||||||
|
- file: mobile/assets/i18n/nl-NL.json
|
||||||
|
locale_code: nl-NL
|
||||||
|
- file: mobile/assets/i18n/ko-KR.json
|
||||||
|
locale_code: ko-KR
|
||||||
|
- file: mobile/assets/i18n/da-DK.json
|
||||||
|
locale_code: da-DK
|
||||||
download:
|
download:
|
||||||
files:
|
files:
|
||||||
- file: mobile/assets/i18n/en-US.json
|
- file: mobile/assets/i18n/en-US.json
|
||||||
@@ -17,3 +25,11 @@ download:
|
|||||||
locale_code: de-DE
|
locale_code: de-DE
|
||||||
- file: mobile/assets/i18n/fr-FR.json
|
- file: mobile/assets/i18n/fr-FR.json
|
||||||
locale_code: fr-FR
|
locale_code: fr-FR
|
||||||
|
- file: mobile/assets/i18n/it-IT.json
|
||||||
|
locale_code: it-IT
|
||||||
|
- file: mobile/assets/i18n/nl-NL.json
|
||||||
|
locale_code: nl-NL
|
||||||
|
- file: mobile/assets/i18n/ko-KR.json
|
||||||
|
locale_code: ko-KR
|
||||||
|
- file: mobile/assets/i18n/da-DK.json
|
||||||
|
locale_code: da-DK
|
||||||
|
|||||||
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/core": "^8.0.0",
|
||||||
"@nestjs/mapped-types": "^1.0.1",
|
"@nestjs/mapped-types": "^1.0.1",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^8.0.0",
|
||||||
"@nestjs/typeorm": "^8.0.3",
|
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||||
"@tensorflow/tfjs": "^3.19.0",
|
"@tensorflow/tfjs": "^3.19.0",
|
||||||
@@ -34,11 +33,9 @@
|
|||||||
"@tensorflow/tfjs-node": "^3.19.0",
|
"@tensorflow/tfjs-node": "^3.19.0",
|
||||||
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
||||||
"@trpc/server": "^9.20.3",
|
"@trpc/server": "^9.20.3",
|
||||||
"pg": "^8.7.3",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0"
|
||||||
"typeorm": "^0.2.45"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^8.2.4",
|
"@nestjs/cli": "^8.2.4",
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
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';
|
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [ImageClassifierModule, ObjectDetectionModule],
|
||||||
TypeOrmModule.forRoot(databaseConfig),
|
|
||||||
ImageClassifierModule,
|
|
||||||
ObjectDetectionModule,
|
|
||||||
],
|
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
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,
|
|
||||||
};
|
|
||||||
3
mobile/android/.gitignore
vendored
3
mobile/android/.gitignore
vendored
@@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java
|
|||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
/fastlane/report.xml
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId "app.alextran.immich"
|
applicationId "app.alextran.immich"
|
||||||
minSdkVersion 21
|
minSdkVersion 23
|
||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdkVersion flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
<service android:name=".AppClearedService" android:stopWithTask="false" />
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches the event when either the system or the user kills the app
|
||||||
|
* (does not apply on force close!)
|
||||||
|
*/
|
||||||
|
class AppClearedService() : Service() {
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent) {
|
||||||
|
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
|
||||||
|
stopSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
import android.content.Context
|
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.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
@@ -44,51 +39,33 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val ctx = context!!
|
val ctx = context!!
|
||||||
when(call.method) {
|
when(call.method) {
|
||||||
"initialize" -> { // needs to be called prior to any other method
|
"enable" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
|
.edit()
|
||||||
|
.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)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"start" -> {
|
"configure" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val immediate = args.get(0) as Boolean
|
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||||
val keepExisting = args.get(1) as Boolean
|
val requireCharging = args.get(1) as Boolean
|
||||||
val requireUnmeteredNetwork = args.get(2) as Boolean
|
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
||||||
val requireCharging = args.get(3) as Boolean
|
result.success(true)
|
||||||
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)
|
|
||||||
result.success(true)
|
|
||||||
}
|
}
|
||||||
"stop" -> {
|
"disable" -> {
|
||||||
|
ContentObserverWorker.disable(ctx)
|
||||||
BackupWorker.stopWork(ctx)
|
BackupWorker.stopWork(ctx)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"isEnabled" -> {
|
"isEnabled" -> {
|
||||||
result.success(BackupWorker.isEnabled(ctx))
|
result.success(ContentObserverWorker.isEnabled(ctx))
|
||||||
}
|
}
|
||||||
"disableBatteryOptimizations" -> {
|
"isIgnoringBatteryOptimizations" -> {
|
||||||
if(!BackupWorker.isIgnoringBatteryOptimizations(ctx)) {
|
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
|
||||||
val text = args.get(0) as String
|
|
||||||
Toast.makeText(ctx, text, Toast.LENGTH_LONG).show()
|
|
||||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
intent.setData(Uri.parse("package:" + ctx.getPackageName()))
|
|
||||||
try {
|
|
||||||
ctx.startActivity(intent)
|
|
||||||
} catch(e: Exception) {
|
|
||||||
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
|
||||||
try {
|
|
||||||
ctx.startActivity(intent)
|
|
||||||
} catch (e2: Exception) {
|
|
||||||
return result.success(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(true)
|
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,12 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.os.SystemClock
|
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 android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.concurrent.futures.ResolvableFuture
|
import androidx.concurrent.futures.ResolvableFuture
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.Data
|
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
|
|||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkInfo
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
@@ -41,14 +37,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
|
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
|
||||||
* `background.service.dart` to run the actual backup logic.
|
* `background.service.dart` to run the actual backup logic.
|
||||||
* Called by Android WorkManager when all constraints for the work are met,
|
* 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.
|
* i.e. battery is not low and optionally Wifi and charging are active.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
|
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
@@ -57,14 +46,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
private lateinit var backgroundChannel: MethodChannel
|
private lateinit var backgroundChannel: MethodChannel
|
||||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||||
|
private var timeBackupStarted: Long = 0L
|
||||||
|
|
||||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||||
|
|
||||||
|
Log.d(TAG, "startWork")
|
||||||
|
|
||||||
val ctx = applicationContext
|
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()) {
|
if (!flutterLoader.initialized()) {
|
||||||
flutterLoader.startInitialization(ctx)
|
flutterLoader.startInitialization(ctx)
|
||||||
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
// Create a Notification channel if necessary
|
// Create a Notification channel if necessary
|
||||||
createChannel()
|
createChannel()
|
||||||
}
|
}
|
||||||
|
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||||
if (isIgnoringBatteryOptimizations) {
|
if (isIgnoringBatteryOptimizations) {
|
||||||
// normal background services can only up to 10 minutes
|
// normal background services can only up to 10 minutes
|
||||||
// foreground services are allowed to run indefinitely
|
// foreground services are allowed to run indefinitely
|
||||||
// requires battery optimizations to be disabled (either manually by the user
|
// requires battery optimizations to be disabled (either manually by the user
|
||||||
// or by the system learning that immich is important to the user)
|
// 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))
|
setForegroundAsync(createForegroundInfo(title))
|
||||||
|
} else {
|
||||||
|
showBackgroundInfo(title)
|
||||||
}
|
}
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
|
||||||
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStopped() {
|
override fun onStopped() {
|
||||||
|
Log.d(TAG, "onStopped")
|
||||||
// called when the system has to stop this worker because constraints are
|
// called when the system has to stop this worker because constraints are
|
||||||
// no longer met or the system needs resources for more important tasks
|
// no longer met or the system needs resources for more important tasks
|
||||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||||
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
|
|
||||||
private fun stopEngine(result: Result?) {
|
private fun stopEngine(result: Result?) {
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
|
Log.d(TAG, "stopEngine result=${result}")
|
||||||
resolvableFuture.set(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?.destroy()
|
||||||
engine = null
|
engine = null
|
||||||
|
clearBackgroundNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"initialized" ->
|
"initialized" -> {
|
||||||
|
timeBackupStarted = SystemClock.uptimeMillis()
|
||||||
backgroundChannel.invokeMethod(
|
backgroundChannel.invokeMethod(
|
||||||
"onAssetsChanged",
|
"onAssetsChanged",
|
||||||
null,
|
null,
|
||||||
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
override fun success(receivedResult: Any?) {
|
override fun success(receivedResult: Any?) {
|
||||||
val success = receivedResult as Boolean
|
val success = receivedResult as Boolean
|
||||||
stopEngine(if(success) Result.success() else Result.retry())
|
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" -> {
|
"updateNotification" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String
|
||||||
if (isIgnoringBatteryOptimizations) {
|
if (isIgnoringBatteryOptimizations) {
|
||||||
setForegroundAsync(createForegroundInfo(title, content))
|
setForegroundAsync(createForegroundInfo(title, content))
|
||||||
|
} else {
|
||||||
|
showBackgroundInfo(title, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"showError" -> {
|
"showError" -> {
|
||||||
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
showError(title, content, individualTag)
|
showError(title, content, individualTag)
|
||||||
}
|
}
|
||||||
"clearErrorNotifications" -> clearErrorNotifications()
|
"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()
|
else -> r.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setTicker(title)
|
||||||
|
.setContentText(content)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearBackgroundNotification() {
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
companion object {
|
companion object {
|
||||||
const val SHARED_PREF_NAME = "immichBackgroundService"
|
const val SHARED_PREF_NAME = "immichBackgroundService"
|
||||||
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
|
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_NOTIFICATION_TITLE = "notificationTitle"
|
||||||
|
const val SHARED_PREF_LAST_CHANGE = "lastChange"
|
||||||
|
|
||||||
private const val TASK_NAME = "immich/photoListener"
|
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
|
||||||
private const val DATA_KEY_UNMETERED = "unmetered"
|
|
||||||
private const val DATA_KEY_CHARGING = "charging"
|
|
||||||
private const val DATA_KEY_RETRIES = "retries"
|
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val NOTIFICATION_ERROR_ID = 2
|
private const val NOTIFICATION_ERROR_ID = 2
|
||||||
private const val ONE_MINUTE: Long = 60000
|
private const val ONE_MINUTE = 60000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues the `BackupWorker` to run when all constraints are met.
|
* Enqueues the BackupWorker to run once the 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)
|
|
||||||
*/
|
*/
|
||||||
fun startWork(context: Context,
|
fun enqueueBackupWorker(context: Context,
|
||||||
immediate: Boolean = false,
|
requireWifi: Boolean = false,
|
||||||
keepExisting: Boolean = false,
|
requireCharging: Boolean = false,
|
||||||
requireUnmeteredNetwork: Boolean = false,
|
delayMilliseconds: Long = 0L) {
|
||||||
requireCharging: Boolean = false) {
|
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
|
||||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
|
||||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
|
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
|
||||||
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueMoreWork(context: Context,
|
/**
|
||||||
immediate: Boolean = false,
|
* Updates the constraints of an already enqueued BackupWorker
|
||||||
keepExisting: Boolean = false,
|
*/
|
||||||
requireUnmeteredNetwork: Boolean = false,
|
fun updateBackupWorker(context: Context,
|
||||||
requireCharging: Boolean = false,
|
requireWifi: Boolean = false,
|
||||||
initialDelayInMs: Long = 0,
|
requireCharging: Boolean = false) {
|
||||||
retries: Int = 0) {
|
try {
|
||||||
if (!isEnabled(context)) {
|
val wm = WorkManager.getInstance(context)
|
||||||
return
|
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
|
* Stops the currently running worker (if any) and removes it from the work queue
|
||||||
*/
|
*/
|
||||||
fun stopWork(context: Context) {
|
fun stopWork(context: Context) {
|
||||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
|
||||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
Log.d(TAG, "stopWork: BackupWorker cancelled")
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun buildWorkRequest(requireWifi: Boolean = false,
|
||||||
* Return true if the user has enabled the background backup service
|
requireCharging: Boolean = false,
|
||||||
*/
|
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
|
||||||
fun isEnabled(ctx: Context): Boolean {
|
val constraints = Constraints.Builder()
|
||||||
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
.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()
|
private val flutterLoader = FlutterLoader()
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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) {
|
||||||
|
// migration to remove any old active background task
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
||||||
|
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
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"
|
||||||
@@ -2,6 +2,8 @@ package app.alextran.immich
|
|||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
|
|
||||||
@@ -10,4 +12,14 @@ class MainActivity: FlutterActivity() {
|
|||||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
try {
|
||||||
|
startService(Intent(getBaseContext(), AppClearedService::class.java));
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// startService must not be called when app is in background (crashes app)
|
||||||
|
// there is nothing we can do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,17 @@
|
|||||||
default_platform(:android)
|
default_platform(:android)
|
||||||
|
|
||||||
platform :android do
|
platform :android do
|
||||||
desc "Build Android"
|
desc "Build Android and Release Testing"
|
||||||
lane :build do
|
lane :beta do
|
||||||
gradle(
|
gradle(
|
||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
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
|
end
|
||||||
|
|
||||||
desc "Build and Release Android"
|
desc "Build and Release Android"
|
||||||
@@ -30,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 35,
|
"android.injected.version.code" => 48,
|
||||||
"android.injected.version.name" => "1.25.0",
|
"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')
|
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
|
||||||
|
|
||||||
### android build
|
### android beta
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
[bundle exec] fastlane android build
|
[bundle exec] fastlane android beta
|
||||||
```
|
```
|
||||||
|
|
||||||
Build Android
|
Build Android and Release Testing
|
||||||
|
|
||||||
### android release
|
### android release
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fixed rendering blank when failed to parse datetime on main timeline
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Add cache setting and improve caching mechanism
|
||||||
|
* Persist WiFi + charging settings of background backup
|
||||||
@@ -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.
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000212">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="3.608039">
|
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
|
||||||
|
|
||||||
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
|
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"album_info_card_backup_album_excluded": "AUSGESCHLOSSEN",
|
"album_info_card_backup_album_excluded": "AUSGESCHLOSSEN",
|
||||||
"album_info_card_backup_album_included": "EINGESCHLOSSEN",
|
"album_info_card_backup_album_included": "EINGESCHLOSSEN",
|
||||||
|
"album_thumbnail_card_item": "1 Element",
|
||||||
|
"album_thumbnail_card_items": "{} Elemente",
|
||||||
|
"album_thumbnail_card_shared": " · Geteilt",
|
||||||
"album_viewer_appbar_share_delete": "Album löschen",
|
"album_viewer_appbar_share_delete": "Album löschen",
|
||||||
"album_viewer_appbar_share_err_delete": "Album konnte nicht gelöscht werden",
|
"album_viewer_appbar_share_err_delete": "Album konnte nicht gelöscht werden",
|
||||||
"album_viewer_appbar_share_err_leave": "Album konnte nicht verlassen werden",
|
"album_viewer_appbar_share_err_leave": "Album konnte nicht verlassen werden",
|
||||||
@@ -46,9 +49,11 @@
|
|||||||
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
|
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
|
||||||
"backup_info_card_assets": "Elemente",
|
"backup_info_card_assets": "Elemente",
|
||||||
"control_bottom_app_bar_delete": "Löschen",
|
"control_bottom_app_bar_delete": "Löschen",
|
||||||
"create_shared_album_page_share": "Teilen",
|
"control_bottom_app_bar_share": "Teilen",
|
||||||
|
"create_album_page_untitled": "Unbenannt",
|
||||||
"create_shared_album_page_create": "Erstellen",
|
"create_shared_album_page_create": "Erstellen",
|
||||||
"create_shared_album_page_share_add_assets": "FOTOS HINZUFÜGEN",
|
"create_shared_album_page_share": "Teilen",
|
||||||
|
"create_shared_album_page_share_add_assets": "ELEMENTE HINZUFÜGEN",
|
||||||
"create_shared_album_page_share_select_photos": "Fotos auswählen",
|
"create_shared_album_page_share_select_photos": "Fotos auswählen",
|
||||||
"daily_title_text_date": "E, dd MMM",
|
"daily_title_text_date": "E, dd MMM",
|
||||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||||
@@ -60,6 +65,8 @@
|
|||||||
"exif_bottom_sheet_description": "Beschreibung hinzufügen...",
|
"exif_bottom_sheet_description": "Beschreibung hinzufügen...",
|
||||||
"exif_bottom_sheet_details": "DETAILS",
|
"exif_bottom_sheet_details": "DETAILS",
|
||||||
"exif_bottom_sheet_location": "STANDORT",
|
"exif_bottom_sheet_location": "STANDORT",
|
||||||
|
"library_page_albums": "Alben",
|
||||||
|
"library_page_new_album": "Neues Album",
|
||||||
"login_form_button_text": "Anmelden",
|
"login_form_button_text": "Anmelden",
|
||||||
"login_form_email_hint": "deine@email.de",
|
"login_form_email_hint": "deine@email.de",
|
||||||
"login_form_endpoint_hint": "http://deine-server-ip:port/api",
|
"login_form_endpoint_hint": "http://deine-server-ip:port/api",
|
||||||
@@ -68,15 +75,15 @@
|
|||||||
"login_form_err_invalid_email": "Ungültige E-Mail",
|
"login_form_err_invalid_email": "Ungültige E-Mail",
|
||||||
"login_form_err_leading_whitespace": "Führendes Leerzichen",
|
"login_form_err_leading_whitespace": "Führendes Leerzichen",
|
||||||
"login_form_err_trailing_whitespace": "Folgendes Leerzeichen",
|
"login_form_err_trailing_whitespace": "Folgendes Leerzeichen",
|
||||||
"login_form_failed_login": "Fehler bei der Anmeldung, überprüfen Sie Server URL, E-Mail und Passwort",
|
"login_form_failed_login": "Error logging you in, check server url, email and password",
|
||||||
"login_form_label_email": "E-Mail",
|
"login_form_label_email": "E-Mail",
|
||||||
"login_form_label_password": "Passwort",
|
"login_form_label_password": "Passwort",
|
||||||
"login_form_password_hint": "Passwort",
|
"login_form_password_hint": "password",
|
||||||
"login_form_save_login": "Angemeldet bleiben",
|
"login_form_save_login": "Angemeldet bleiben",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"profile_drawer_client_server_up_to_date": "App und Server sind aktuell",
|
"profile_drawer_client_server_up_to_date": "App und Server sind aktuell",
|
||||||
"profile_drawer_sign_out": "Abmelden",
|
|
||||||
"profile_drawer_settings": "Einstellungen",
|
"profile_drawer_settings": "Einstellungen",
|
||||||
|
"profile_drawer_sign_out": "Abmelden",
|
||||||
"search_bar_hint": "Durchsuche deine Fotos",
|
"search_bar_hint": "Durchsuche deine Fotos",
|
||||||
"search_page_no_objects": "Keine Objektinformationen verfügbar",
|
"search_page_no_objects": "Keine Objektinformationen verfügbar",
|
||||||
"search_page_no_places": "Keine Informationen über Orte verfügbar",
|
"search_page_no_places": "Keine Informationen über Orte verfügbar",
|
||||||
@@ -85,42 +92,35 @@
|
|||||||
"search_result_page_new_search_hint": "Neue Suche",
|
"search_result_page_new_search_hint": "Neue Suche",
|
||||||
"select_additional_user_for_sharing_page_suggestions": "Vorschläge",
|
"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_err_album": "Album konnte nicht erstellt werden",
|
||||||
"select_user_for_sharing_page_share_suggestions": "Vorschläge",
|
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||||
|
"setting_pages_app_bar_settings": "Einstellungen",
|
||||||
"share_add": "Hinzufügen",
|
"share_add": "Hinzufügen",
|
||||||
"share_add_photos": "Fotos hinzufügen",
|
"share_add_photos": "Fotos hinzufügen",
|
||||||
"share_add_title": "Titel hinzufügen",
|
"share_add_title": "Titel hinzufügen",
|
||||||
"share_create_album": "Album erstellen",
|
"share_create_album": "Album erstellen",
|
||||||
|
"share_dialog_preparing": "Vorbereiten...",
|
||||||
"share_invite": "Zum Album einladen",
|
"share_invite": "Zum Album einladen",
|
||||||
"sharing_page_album": "Geteilte Alben",
|
"sharing_page_album": "Geteilte Alben",
|
||||||
"sharing_page_description": "Erstelle ein geteiltes Album um Fotos und Videos mit Personen in deinem Netzwerk zu teilen.",
|
"sharing_page_description": "Erstelle ein geteiltes Album um Fotos und Videos mit Personen in deinem Netzwerk zu teilen.",
|
||||||
"sharing_page_empty_list": "LEERE LISTE",
|
"sharing_page_empty_list": "LEERE LISTE",
|
||||||
"sharing_silver_appbar_create_shared_album": "Neues geteiltes Album",
|
"sharing_silver_appbar_create_shared_album": "Neues geteiltes Album",
|
||||||
"sharing_silver_appbar_share_partner": "Teile mit Partner",
|
"sharing_silver_appbar_share_partner": "Teile mit Partner",
|
||||||
|
"tab_controller_nav_library": "Bibliothek",
|
||||||
"tab_controller_nav_photos": "Fotos",
|
"tab_controller_nav_photos": "Fotos",
|
||||||
"tab_controller_nav_search": "Suche",
|
"tab_controller_nav_search": "Suche",
|
||||||
"tab_controller_nav_sharing": "Teilen",
|
"tab_controller_nav_sharing": "Teilen",
|
||||||
"tab_controller_nav_library": "Bibliothek",
|
"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",
|
||||||
|
"theme_setting_system_theme_switch": "Automatisch (Systemeinstellung folgen)",
|
||||||
|
"theme_setting_theme_subtitle": "Wählen Sie die Themeneinstellung der App",
|
||||||
|
"theme_setting_theme_title": "Theme",
|
||||||
|
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich",
|
||||||
|
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
|
||||||
"version_announcement_overlay_ack": "Ich habe verstanden",
|
"version_announcement_overlay_ack": "Ich habe verstanden",
|
||||||
"version_announcement_overlay_release_notes": "Änderungsprotokoll",
|
"version_announcement_overlay_release_notes": "Änderungsprotokoll",
|
||||||
"version_announcement_overlay_text_1": "Hallo mein Freund! Es gibt eine neue Version von",
|
"version_announcement_overlay_text_1": "Hallo mein Freund! Es gibt eine neue Version von",
|
||||||
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
|
"version_announcement_overlay_text_2": "Bitte nehm dir die Zeit und lese das ",
|
||||||
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
|
"version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).",
|
||||||
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89",
|
"version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89"
|
||||||
"album_thumbnail_card_item": "1 Element",
|
}
|
||||||
"album_thumbnail_card_items": "{} Elemente",
|
|
||||||
"album_thumbnail_card_shared": " · Geteilt",
|
|
||||||
"library_page_albums": "Alben",
|
|
||||||
"library_page_new_album": "Neues Album",
|
|
||||||
"create_album_page_untitled": "Unbenannt",
|
|
||||||
"share_dialog_preparing": "Vorbereiten...",
|
|
||||||
"control_bottom_app_bar_share": "Teilen",
|
|
||||||
"setting_pages_app_bar_settings": "Einstellungen",
|
|
||||||
"theme_setting_theme_title": "Theme",
|
|
||||||
"theme_setting_theme_subtitle": "Wählen Sie die Themeneinstellung der App",
|
|
||||||
"theme_setting_system_theme_switch": "Automatisch (Systemeinstellung folgen)",
|
|
||||||
"theme_setting_dark_mode_switch": "Dunkler Modus",
|
|
||||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
|
||||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
|
||||||
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
|
|
||||||
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"album_info_card_backup_album_excluded": "EXCLUDED",
|
"album_info_card_backup_album_excluded": "EXCLUDED",
|
||||||
"album_info_card_backup_album_included": "INCLUDED",
|
"album_info_card_backup_album_included": "INCLUDED",
|
||||||
|
"album_thumbnail_card_item": "1 item",
|
||||||
|
"album_thumbnail_card_items": "{} items",
|
||||||
|
"album_thumbnail_card_shared": " · Shared",
|
||||||
"album_viewer_appbar_share_delete": "Delete album",
|
"album_viewer_appbar_share_delete": "Delete album",
|
||||||
"album_viewer_appbar_share_err_delete": "Failed to delete album",
|
"album_viewer_appbar_share_err_delete": "Failed to delete album",
|
||||||
"album_viewer_appbar_share_err_leave": "Failed to leave album",
|
"album_viewer_appbar_share_err_leave": "Failed to leave album",
|
||||||
@@ -9,6 +12,8 @@
|
|||||||
"album_viewer_appbar_share_leave": "Leave album",
|
"album_viewer_appbar_share_leave": "Leave album",
|
||||||
"album_viewer_appbar_share_remove": "Remove from album",
|
"album_viewer_appbar_share_remove": "Remove from album",
|
||||||
"album_viewer_page_share_add_users": "Add users",
|
"album_viewer_page_share_add_users": "Add users",
|
||||||
|
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||||
|
"asset_list_settings_title": "Photo Grid",
|
||||||
"backup_album_selection_page_albums_device": "Albums on device ({})",
|
"backup_album_selection_page_albums_device": "Albums on device ({})",
|
||||||
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
||||||
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
|
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
|
||||||
@@ -16,26 +21,29 @@
|
|||||||
"backup_album_selection_page_selection_info": "Selection Info",
|
"backup_album_selection_page_selection_info": "Selection Info",
|
||||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||||
"backup_all": "All",
|
"backup_all": "All",
|
||||||
"backup_background_service_default_notification": "Checking for new assets…",
|
|
||||||
"backup_background_service_disable_battery_optimizations": "Please disable battery optimization for Immich to enable background backup",
|
|
||||||
"backup_background_service_upload_failure_notification": "Failed to upload {}",
|
|
||||||
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
|
||||||
"backup_background_service_current_upload_notification": "Uploading {}",
|
|
||||||
"backup_background_service_error_title": "Backup error",
|
|
||||||
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
|
||||||
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||||
|
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
||||||
|
"backup_background_service_current_upload_notification": "Uploading {}",
|
||||||
|
"backup_background_service_default_notification": "Checking for new assets…",
|
||||||
|
"backup_background_service_error_title": "Backup error",
|
||||||
|
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
||||||
|
"backup_background_service_upload_failure_notification": "Failed to upload {}",
|
||||||
"backup_controller_page_albums": "Backup Albums",
|
"backup_controller_page_albums": "Backup Albums",
|
||||||
|
"backup_controller_page_background_battery_info_link": "Show me how",
|
||||||
|
"backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.",
|
||||||
|
"backup_controller_page_background_battery_info_ok": "OK",
|
||||||
|
"backup_controller_page_background_battery_info_title": "Battery optimizations",
|
||||||
|
"backup_controller_page_background_charging": "Only while charging",
|
||||||
|
"backup_controller_page_background_configure_error": "Failed to configure the background service",
|
||||||
|
"backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app",
|
||||||
|
"backup_controller_page_background_is_off": "Automatic background backup is off",
|
||||||
|
"backup_controller_page_background_is_on": "Automatic background backup is on",
|
||||||
|
"backup_controller_page_background_turn_off": "Turn off background service",
|
||||||
|
"backup_controller_page_background_turn_on": "Turn on background service",
|
||||||
|
"backup_controller_page_background_wifi": "Only on WiFi",
|
||||||
"backup_controller_page_backup": "Backup",
|
"backup_controller_page_backup": "Backup",
|
||||||
"backup_controller_page_backup_selected": "Selected: ",
|
"backup_controller_page_backup_selected": "Selected: ",
|
||||||
"backup_controller_page_backup_sub": "Backed up photos and videos",
|
"backup_controller_page_backup_sub": "Backed up photos and videos",
|
||||||
"backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app",
|
|
||||||
"backup_controller_page_background_wifi": "Only on WiFi",
|
|
||||||
"backup_controller_page_background_charging": "Only while charging",
|
|
||||||
"backup_controller_page_background_is_on": "Automatic background backup is on",
|
|
||||||
"backup_controller_page_background_is_off": "Automatic background backup is off",
|
|
||||||
"backup_controller_page_background_turn_on": "Turn on background service",
|
|
||||||
"backup_controller_page_background_turn_off": "Turn off background service",
|
|
||||||
"backup_controller_page_background_configure_error": "Failed to configure the background service",
|
|
||||||
"backup_controller_page_cancel": "Cancel",
|
"backup_controller_page_cancel": "Cancel",
|
||||||
"backup_controller_page_created": "Created on: {}",
|
"backup_controller_page_created": "Created on: {}",
|
||||||
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
|
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
|
||||||
@@ -61,10 +69,25 @@
|
|||||||
"backup_controller_page_uploading_file_info": "Uploading file info",
|
"backup_controller_page_uploading_file_info": "Uploading file info",
|
||||||
"backup_err_only_album": "Cannot remove the only album",
|
"backup_err_only_album": "Cannot remove the only album",
|
||||||
"backup_info_card_assets": "assets",
|
"backup_info_card_assets": "assets",
|
||||||
|
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
|
||||||
|
"cache_settings_clear_cache_button": "Clear cache",
|
||||||
|
"cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
|
||||||
|
"cache_settings_image_cache_size": "Image cache size ({} assets)",
|
||||||
|
"cache_settings_statistics_album": "Library thumbnails",
|
||||||
|
"cache_settings_statistics_assets": "{} assets ({})",
|
||||||
|
"cache_settings_statistics_full": "Full images",
|
||||||
|
"cache_settings_statistics_shared": "Shared album thumbnails",
|
||||||
|
"cache_settings_statistics_thumbnail": "Thumbnails",
|
||||||
|
"cache_settings_statistics_title": "Cache usage",
|
||||||
|
"cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application",
|
||||||
|
"cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)",
|
||||||
|
"cache_settings_title": "Caching Settings",
|
||||||
"control_bottom_app_bar_delete": "Delete",
|
"control_bottom_app_bar_delete": "Delete",
|
||||||
"create_shared_album_page_share": "Share",
|
"control_bottom_app_bar_share": "Share",
|
||||||
|
"create_album_page_untitled": "Untitled",
|
||||||
"create_shared_album_page_create": "Create",
|
"create_shared_album_page_create": "Create",
|
||||||
"create_shared_album_page_share_add_assets": "ADD PHOTOS",
|
"create_shared_album_page_share": "Share",
|
||||||
|
"create_shared_album_page_share_add_assets": "ADD ASSETS",
|
||||||
"create_shared_album_page_share_select_photos": "Select Photos",
|
"create_shared_album_page_share_select_photos": "Select Photos",
|
||||||
"daily_title_text_date": "E, MMM dd",
|
"daily_title_text_date": "E, MMM dd",
|
||||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||||
@@ -76,6 +99,8 @@
|
|||||||
"exif_bottom_sheet_description": "Add Description...",
|
"exif_bottom_sheet_description": "Add Description...",
|
||||||
"exif_bottom_sheet_details": "DETAILS",
|
"exif_bottom_sheet_details": "DETAILS",
|
||||||
"exif_bottom_sheet_location": "LOCATION",
|
"exif_bottom_sheet_location": "LOCATION",
|
||||||
|
"library_page_albums": "Albums",
|
||||||
|
"library_page_new_album": "New album",
|
||||||
"login_form_button_text": "Login",
|
"login_form_button_text": "Login",
|
||||||
"login_form_email_hint": "youremail@email.com",
|
"login_form_email_hint": "youremail@email.com",
|
||||||
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
||||||
@@ -91,8 +116,8 @@
|
|||||||
"login_form_save_login": "Stay logged in",
|
"login_form_save_login": "Stay logged in",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
||||||
"profile_drawer_sign_out": "Sign out",
|
|
||||||
"profile_drawer_settings": "Settings",
|
"profile_drawer_settings": "Settings",
|
||||||
|
"profile_drawer_sign_out": "Sign Out",
|
||||||
"search_bar_hint": "Search your photos",
|
"search_bar_hint": "Search your photos",
|
||||||
"search_page_no_objects": "No Objects Info Available",
|
"search_page_no_objects": "No Objects Info Available",
|
||||||
"search_page_no_places": "No Places Info Available",
|
"search_page_no_places": "No Places Info Available",
|
||||||
@@ -102,52 +127,48 @@
|
|||||||
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
|
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
|
||||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||||
|
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
|
||||||
|
"setting_notifications_notify_hours": "{} hours",
|
||||||
|
"setting_notifications_notify_immediately": "immediately",
|
||||||
|
"setting_notifications_notify_minutes": "{} minutes",
|
||||||
|
"setting_notifications_notify_never": "never",
|
||||||
|
"setting_notifications_subtitle": "Adjust your notification preferences",
|
||||||
|
"setting_notifications_title": "Notifications",
|
||||||
|
"setting_pages_app_bar_settings": "Settings",
|
||||||
"share_add": "Add",
|
"share_add": "Add",
|
||||||
"share_add_photos": "Add photos",
|
"share_add_photos": "Add photos",
|
||||||
"share_add_title": "Add a title",
|
"share_add_title": "Add a title",
|
||||||
"share_create_album": "Create album",
|
"share_create_album": "Create album",
|
||||||
|
"share_dialog_preparing": "Preparing...",
|
||||||
"share_invite": "Invite to album",
|
"share_invite": "Invite to album",
|
||||||
"sharing_page_album": "Shared albums",
|
"sharing_page_album": "Shared albums",
|
||||||
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
|
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
|
||||||
"sharing_page_empty_list": "EMPTY LIST",
|
"sharing_page_empty_list": "EMPTY LIST",
|
||||||
"sharing_silver_appbar_create_shared_album": "Create shared album",
|
"sharing_silver_appbar_create_shared_album": "Create shared album",
|
||||||
"sharing_silver_appbar_share_partner": "Share with partner",
|
"sharing_silver_appbar_share_partner": "Share with partner",
|
||||||
|
"tab_controller_nav_library": "Library",
|
||||||
"tab_controller_nav_photos": "Photos",
|
"tab_controller_nav_photos": "Photos",
|
||||||
"tab_controller_nav_search": "Search",
|
"tab_controller_nav_search": "Search",
|
||||||
"tab_controller_nav_sharing": "Sharing",
|
"tab_controller_nav_sharing": "Sharing",
|
||||||
"tab_controller_nav_library": "Library",
|
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
||||||
|
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
|
||||||
|
"theme_setting_dark_mode_switch": "Dark mode",
|
||||||
|
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
||||||
|
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
||||||
|
"theme_setting_system_theme_switch": "Automatic (Follow system setting)",
|
||||||
|
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
||||||
|
"theme_setting_theme_title": "Theme",
|
||||||
|
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||||
|
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||||
"version_announcement_overlay_ack": "Acknowledge",
|
"version_announcement_overlay_ack": "Acknowledge",
|
||||||
"version_announcement_overlay_release_notes": "release notes",
|
"version_announcement_overlay_release_notes": "release notes",
|
||||||
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
||||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||||
"album_thumbnail_card_item": "1 item",
|
"experimental_settings_title": "Experimental",
|
||||||
"album_thumbnail_card_items": "{} items",
|
"experimental_settings_subtitle": "Use at your own risk!",
|
||||||
"album_thumbnail_card_shared": " · Shared",
|
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||||
"library_page_albums": "Albums",
|
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||||
"library_page_new_album": "New album",
|
"settings_require_restart": "Please restart Immich to apply this setting"
|
||||||
"create_album_page_untitled": "Untitled",
|
}
|
||||||
"share_dialog_preparing": "Preparing...",
|
|
||||||
"control_bottom_app_bar_share": "Share",
|
|
||||||
"setting_pages_app_bar_settings": "Settings",
|
|
||||||
"theme_setting_theme_title": "Theme",
|
|
||||||
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
|
||||||
"theme_setting_system_theme_switch": "Automatic (Follow system setting)",
|
|
||||||
"theme_setting_dark_mode_switch": "Dark mode",
|
|
||||||
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
|
||||||
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
|
||||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
|
||||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
|
||||||
"asset_list_settings_title": "Photo Grid",
|
|
||||||
"asset_list_settings_subtitle": "Photo grid layout settings",
|
|
||||||
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
|
||||||
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
|
|
||||||
"setting_notifications_title": "Notifications",
|
|
||||||
"setting_notifications_subtitle": "Adjust your notification preferences",
|
|
||||||
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
|
|
||||||
"setting_notifications_notify_immediately": "immediately",
|
|
||||||
"setting_notifications_notify_minutes": "{} minutes",
|
|
||||||
"setting_notifications_notify_hours": "{} hours",
|
|
||||||
"setting_notifications_notify_never": "never"
|
|
||||||
}
|
|
||||||
@@ -21,12 +21,8 @@
|
|||||||
"backup_controller_page_backup_selected": "Seleccionado:",
|
"backup_controller_page_backup_selected": "Seleccionado:",
|
||||||
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
|
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
|
||||||
"backup_controller_page_cancel": "Cancelar",
|
"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_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos activos al servidor.",
|
||||||
"backup_controller_page_excluded": "Excluido:",
|
"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_info": "Información de la Copia de Seguridad",
|
||||||
"backup_controller_page_none_selected": "Ninguno seleccionado",
|
"backup_controller_page_none_selected": "Ninguno seleccionado",
|
||||||
"backup_controller_page_remainder": "Remanente",
|
"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_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_off": "Apagar la copia de seguridad",
|
||||||
"backup_controller_page_turn_on": "Activar 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_err_only_album": "No se puede eliminar el único álbum",
|
||||||
"backup_info_card_assets": "activos",
|
"backup_info_card_assets": "activos",
|
||||||
"control_bottom_app_bar_delete": "Eliminar",
|
"control_bottom_app_bar_delete": "Eliminar",
|
||||||
@@ -67,7 +62,6 @@
|
|||||||
"login_form_err_invalid_email": "Correo electrónico no válido",
|
"login_form_err_invalid_email": "Correo electrónico no válido",
|
||||||
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
|
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
|
||||||
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
|
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
|
||||||
"login_form_failed_login": "",
|
|
||||||
"login_form_label_email": "Correo",
|
"login_form_label_email": "Correo",
|
||||||
"login_form_label_password": "Contraseña",
|
"login_form_label_password": "Contraseña",
|
||||||
"login_form_password_hint": "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_client_server_up_to_date": "El Cliente y el Servidor están actualizados",
|
||||||
"profile_drawer_sign_out": "Cerrar Sesión",
|
"profile_drawer_sign_out": "Cerrar Sesión",
|
||||||
"search_bar_hint": "Busca tus fotos",
|
"search_bar_hint": "Busca tus fotos",
|
||||||
"search_page_no_objects": "",
|
|
||||||
"search_page_no_places": "No hay información de lugares disponibles",
|
"search_page_no_places": "No hay información de lugares disponibles",
|
||||||
"search_page_places": "Lugares",
|
"search_page_places": "Lugares",
|
||||||
"search_page_things": "Cosas",
|
"search_page_things": "Cosas",
|
||||||
"search_result_page_new_search_hint": "Nueva Busqueda",
|
"search_result_page_new_search_hint": "Nueva Busqueda",
|
||||||
"select_additional_user_for_sharing_page_suggestions": "Sugerencias",
|
"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_err_album": "Fallo al crear el álbum",
|
||||||
"select_user_for_sharing_page_share_suggestions": "",
|
|
||||||
"share_add": "Añadir",
|
"share_add": "Añadir",
|
||||||
"share_add_photos": "Añadir fotos",
|
"share_add_photos": "Añadir fotos",
|
||||||
"share_add_title": "Añadir un título",
|
"share_add_title": "Añadir un título",
|
||||||
|
|||||||
@@ -49,9 +49,6 @@
|
|||||||
"create_shared_album_page_share": "Jaa",
|
"create_shared_album_page_share": "Jaa",
|
||||||
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
|
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
|
||||||
"create_shared_album_page_share_select_photos": "Valitse kuvat",
|
"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_alert": "Nämä kohteet poistetaan pysyvästi Immich:stä ja laitteeltasi",
|
||||||
"delete_dialog_cancel": "Peruuta",
|
"delete_dialog_cancel": "Peruuta",
|
||||||
"delete_dialog_ok": "Poista",
|
"delete_dialog_ok": "Poista",
|
||||||
@@ -72,7 +69,6 @@
|
|||||||
"login_form_label_password": "Salasana",
|
"login_form_label_password": "Salasana",
|
||||||
"login_form_password_hint": "salasana",
|
"login_form_password_hint": "salasana",
|
||||||
"login_form_save_login": "Pysy kirjautuneena",
|
"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_client_server_up_to_date": "Asiakassovellus ja palvelin ovat ajan tasalla",
|
||||||
"profile_drawer_sign_out": "Kirjaudu ulos",
|
"profile_drawer_sign_out": "Kirjaudu ulos",
|
||||||
"search_bar_hint": "Etsi kuvia",
|
"search_bar_hint": "Etsi kuvia",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"album_info_card_backup_album_excluded": "EXCLU",
|
"album_info_card_backup_album_excluded": "EXCLU",
|
||||||
"album_info_card_backup_album_included": "INCLUS",
|
"album_info_card_backup_album_included": "INCLUS",
|
||||||
|
"album_thumbnail_card_item": "1 élément",
|
||||||
|
"album_thumbnail_card_items": "{} éléments",
|
||||||
|
"album_thumbnail_card_shared": " · Partagé",
|
||||||
"album_viewer_appbar_share_delete": "Supprimer l'album",
|
"album_viewer_appbar_share_delete": "Supprimer l'album",
|
||||||
"album_viewer_appbar_share_err_delete": "Échec de la suppression de l'album",
|
"album_viewer_appbar_share_err_delete": "Échec de la suppression de l'album",
|
||||||
"album_viewer_appbar_share_err_leave": "Impossible de quitter l'album",
|
"album_viewer_appbar_share_err_leave": "Impossible de quitter l'album",
|
||||||
@@ -46,6 +49,9 @@
|
|||||||
"backup_err_only_album": "Impossible de retirer le seul album",
|
"backup_err_only_album": "Impossible de retirer le seul album",
|
||||||
"backup_info_card_assets": "éléments",
|
"backup_info_card_assets": "éléments",
|
||||||
"control_bottom_app_bar_delete": "Supprimer",
|
"control_bottom_app_bar_delete": "Supprimer",
|
||||||
|
"control_bottom_app_bar_share": "Partager",
|
||||||
|
"create_album_page_untitled": "Sans titre",
|
||||||
|
"create_shared_album_page_create": "Créer",
|
||||||
"create_shared_album_page_share": "Partager",
|
"create_shared_album_page_share": "Partager",
|
||||||
"create_shared_album_page_share_add_assets": "AJOUTER DES ÉLÉMENTS",
|
"create_shared_album_page_share_add_assets": "AJOUTER DES ÉLÉMENTS",
|
||||||
"create_shared_album_page_share_select_photos": "Sélectionner les photos",
|
"create_shared_album_page_share_select_photos": "Sélectionner les photos",
|
||||||
@@ -59,6 +65,8 @@
|
|||||||
"exif_bottom_sheet_description": "Ajouter une description...",
|
"exif_bottom_sheet_description": "Ajouter une description...",
|
||||||
"exif_bottom_sheet_details": "DÉTAILS",
|
"exif_bottom_sheet_details": "DÉTAILS",
|
||||||
"exif_bottom_sheet_location": "LOCALISATION",
|
"exif_bottom_sheet_location": "LOCALISATION",
|
||||||
|
"library_page_albums": "Albums",
|
||||||
|
"library_page_new_album": "Nouvel album",
|
||||||
"login_form_button_text": "Connexion",
|
"login_form_button_text": "Connexion",
|
||||||
"login_form_email_hint": "votreemail@email.com",
|
"login_form_email_hint": "votreemail@email.com",
|
||||||
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
|
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
|
||||||
@@ -74,6 +82,7 @@
|
|||||||
"login_form_save_login": "Rester connecté",
|
"login_form_save_login": "Rester connecté",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"profile_drawer_client_server_up_to_date": "Le client et le serveur sont à jour",
|
"profile_drawer_client_server_up_to_date": "Le client et le serveur sont à jour",
|
||||||
|
"profile_drawer_settings": "Paramètres",
|
||||||
"profile_drawer_sign_out": "Se déconnecter",
|
"profile_drawer_sign_out": "Se déconnecter",
|
||||||
"search_bar_hint": "Rechercher vos photos",
|
"search_bar_hint": "Rechercher vos photos",
|
||||||
"search_page_no_objects": "Aucune information disponible sur les objets",
|
"search_page_no_objects": "Aucune information disponible sur les objets",
|
||||||
@@ -88,12 +97,14 @@
|
|||||||
"share_add_photos": "Ajouter des photos",
|
"share_add_photos": "Ajouter des photos",
|
||||||
"share_add_title": "Ajouter un titre",
|
"share_add_title": "Ajouter un titre",
|
||||||
"share_create_album": "Créer un album",
|
"share_create_album": "Créer un album",
|
||||||
|
"share_dialog_preparing": "Préparation...",
|
||||||
"share_invite": "Inviter à l'album",
|
"share_invite": "Inviter à l'album",
|
||||||
"sharing_page_album": "Albums partagés",
|
"sharing_page_album": "Albums partagés",
|
||||||
"sharing_page_description": "Créez des albums partagés pour partager des photos et des vidéos avec les personnes de votre réseau.",
|
"sharing_page_description": "Créez des albums partagés pour partager des photos et des vidéos avec les personnes de votre réseau.",
|
||||||
"sharing_page_empty_list": "LISTE VIDE",
|
"sharing_page_empty_list": "LISTE VIDE",
|
||||||
"sharing_silver_appbar_create_shared_album": "Créer un album partagé",
|
"sharing_silver_appbar_create_shared_album": "Créer un album partagé",
|
||||||
"sharing_silver_appbar_share_partner": "Partager avec un partenaire",
|
"sharing_silver_appbar_share_partner": "Partager avec un partenaire",
|
||||||
|
"tab_controller_nav_library": "Bibliothèque",
|
||||||
"tab_controller_nav_photos": "Photos",
|
"tab_controller_nav_photos": "Photos",
|
||||||
"tab_controller_nav_search": "Recherche",
|
"tab_controller_nav_search": "Recherche",
|
||||||
"tab_controller_nav_sharing": "Partage",
|
"tab_controller_nav_sharing": "Partage",
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
{
|
{
|
||||||
"album_info_card_backup_album_excluded": "ESCLUSI",
|
"album_info_card_backup_album_excluded": "ESCLUSI",
|
||||||
"album_info_card_backup_album_included": "INCLUSI",
|
"album_info_card_backup_album_included": "INCLUSI",
|
||||||
|
"album_thumbnail_card_item": "1 elemento ",
|
||||||
|
"album_thumbnail_card_items": "{} elementi",
|
||||||
|
"album_thumbnail_card_shared": "Condiviso",
|
||||||
"album_viewer_appbar_share_delete": "Elimina album ",
|
"album_viewer_appbar_share_delete": "Elimina album ",
|
||||||
"album_viewer_appbar_share_err_delete": "Fallito nel cancellare l'album ",
|
"album_viewer_appbar_share_err_delete": "Errore nel cancellare l'album ",
|
||||||
"album_viewer_appbar_share_err_leave": "Fallito nel lasciare l'album ",
|
"album_viewer_appbar_share_err_leave": "Errore nel lasciare l'album ",
|
||||||
"album_viewer_appbar_share_err_remove": "Ci sono problemi nel rimuovere oggetti dall'album ",
|
"album_viewer_appbar_share_err_remove": "Ci sono problemi nel rimuovere oggetti dall'album ",
|
||||||
"album_viewer_appbar_share_err_title": "Fallito nel cambiare titolo dell'album ",
|
"album_viewer_appbar_share_err_title": "Errore nel cambiare il titolo dell'album ",
|
||||||
"album_viewer_appbar_share_leave": "Lascia l'album",
|
"album_viewer_appbar_share_leave": "Lascia album",
|
||||||
"album_viewer_appbar_share_remove": "Rimuovere dall'album ",
|
"album_viewer_appbar_share_remove": "Rimuovere dall'album ",
|
||||||
"album_viewer_page_share_add_users": "Aggiungi utenti",
|
"album_viewer_page_share_add_users": "Aggiungi utenti",
|
||||||
"backup_album_selection_page_albums_device": "Albums nel device ({})",
|
"asset_list_settings_subtitle": "Impostazion del layout della griglia delle foto",
|
||||||
|
"asset_list_settings_title": "Griglia foto",
|
||||||
|
"backup_album_selection_page_albums_device": "Albums sul device ({})",
|
||||||
"backup_album_selection_page_albums_tap": "Tap per includere, doppio tap per escludere.",
|
"backup_album_selection_page_albums_tap": "Tap per includere, doppio tap per escludere.",
|
||||||
"backup_album_selection_page_assets_scatter": "Stesse immagini e video possono trovarsi tra più album, così gli album possono essere inclusi o esclusi dal backup.",
|
"backup_album_selection_page_assets_scatter": "Stesse immagini e video possono trovarsi tra più album, così gli album possono essere inclusi o esclusi dal backup.",
|
||||||
"backup_album_selection_page_select_albums": "Seleziona gli album",
|
"backup_album_selection_page_select_albums": "Seleziona gli album",
|
||||||
"backup_album_selection_page_selection_info": "Informazioni sulla selezione ",
|
"backup_album_selection_page_selection_info": "Informazioni sulla selezione ",
|
||||||
"backup_album_selection_page_total_assets": "Numero totale di oggetti unici",
|
"backup_album_selection_page_total_assets": "Numero totale di oggetti unici",
|
||||||
"backup_all": "Tutti",
|
"backup_all": "Tutti",
|
||||||
"backup_controller_page_albums": "Backup album",
|
"backup_background_service_backup_failed_message": "Impossibile caricare contenuti. Nuovo tentativo…",
|
||||||
|
"backup_background_service_connection_failed_message": "Impossibile connettersi al server. Nuovo tentativo…",
|
||||||
|
"backup_background_service_current_upload_notification": "Caricamento {}",
|
||||||
|
"backup_background_service_default_notification": "Verifica di nuovi contenuti…",
|
||||||
|
"backup_background_service_error_title": "Errore di Backup",
|
||||||
|
"backup_background_service_in_progress_notification": "Backing dei tuoi contenuti…",
|
||||||
|
"backup_background_service_upload_failure_notification": "Impossibile caricare {}",
|
||||||
|
"backup_controller_page_albums": "Backup Album",
|
||||||
|
"backup_controller_page_background_battery_info_link": "Mostrami come",
|
||||||
|
"backup_controller_page_background_battery_info_message": "Per una migliore esperienza di backup, disabilita le ottimisazioni della batteria per l'app Immich.\n\nDal momento che è una funzionalità specifica del dispositivo, per favore consulta il manuale del produttore.",
|
||||||
|
"backup_controller_page_background_battery_info_ok": "OK",
|
||||||
|
"backup_controller_page_background_battery_info_title": "Ottimizzazioni batteria",
|
||||||
|
"backup_controller_page_background_charging": "Solo durante la ricarica",
|
||||||
|
"backup_controller_page_background_configure_error": "Impossibile configurare i servizi in background",
|
||||||
|
"backup_controller_page_background_description": "Abilita i servizi in background per sincronizzare tutti i nuovi contenuti senza la necessità di aprire l'app",
|
||||||
|
"backup_controller_page_background_is_off": "Backup automatico spento",
|
||||||
|
"backup_controller_page_background_is_on": "Backup automatico attivo",
|
||||||
|
"backup_controller_page_background_turn_off": "Disabilita servizi in background",
|
||||||
|
"backup_controller_page_background_turn_on": "Abilita servizi in background",
|
||||||
|
"backup_controller_page_background_wifi": "Solo su WiFi",
|
||||||
"backup_controller_page_backup": "Backup",
|
"backup_controller_page_backup": "Backup",
|
||||||
"backup_controller_page_backup_selected": "Selezionati:",
|
"backup_controller_page_backup_selected": "Selezionati:",
|
||||||
"backup_controller_page_backup_sub": "Photo e video salvati",
|
"backup_controller_page_backup_sub": "Foto e video caricati",
|
||||||
"backup_controller_page_cancel": "Cancella ",
|
"backup_controller_page_cancel": "Cancella ",
|
||||||
"backup_controller_page_created": "Creato il: {}",
|
"backup_controller_page_created": "Creato il: {}",
|
||||||
"backup_controller_page_desc_backup": "Attiva il backup automatico per eseguire upload sul server",
|
"backup_controller_page_desc_backup": "Attiva il backup per eseguire il caricamento automatico sul server",
|
||||||
"backup_controller_page_excluded": "Esclusi:",
|
"backup_controller_page_excluded": "Esclusi:",
|
||||||
"backup_controller_page_failed": "Falliti: ({})",
|
"backup_controller_page_failed": "Falliti: ({})",
|
||||||
"backup_controller_page_filename": "Nome del file: {} [{}]",
|
"backup_controller_page_filename": "Nome del file: {} [{}]",
|
||||||
@@ -30,39 +54,57 @@
|
|||||||
"backup_controller_page_info": "Informazioni sul backup",
|
"backup_controller_page_info": "Informazioni sul backup",
|
||||||
"backup_controller_page_none_selected": "Nessuna selezione",
|
"backup_controller_page_none_selected": "Nessuna selezione",
|
||||||
"backup_controller_page_remainder": "Promemoria ",
|
"backup_controller_page_remainder": "Promemoria ",
|
||||||
"backup_controller_page_remainder_sub": "Photo e album selezionati che rimangono da salvare",
|
"backup_controller_page_remainder_sub": "Photo e album selezionati che rimangono da caricare",
|
||||||
"backup_controller_page_select": "Seleziona ",
|
"backup_controller_page_select": "Seleziona ",
|
||||||
"backup_controller_page_server_storage": "Spazio nel server",
|
"backup_controller_page_server_storage": "Spazio sul server",
|
||||||
"backup_controller_page_start_backup": "Inizia backup ",
|
"backup_controller_page_start_backup": "Inizia backup ",
|
||||||
"backup_controller_page_status_off": "Backup è disattivato ",
|
"backup_controller_page_status_off": "Backup è disattivato ",
|
||||||
"backup_controller_page_status_on": "Backup è attivato",
|
"backup_controller_page_status_on": "Backup è attivato",
|
||||||
"backup_controller_page_storage_format": "{} di {} usati",
|
"backup_controller_page_storage_format": "{} di {} usati",
|
||||||
"backup_controller_page_to_backup": "Album da salvare",
|
"backup_controller_page_to_backup": "Album da caricare",
|
||||||
"backup_controller_page_total": "Totale",
|
"backup_controller_page_total": "Totale",
|
||||||
"backup_controller_page_total_sub": "Tutte le foto e i video unici salvati dagli album selezionati ",
|
"backup_controller_page_total_sub": "Tutte le foto e i video unici caricati dagli album selezionati ",
|
||||||
"backup_controller_page_turn_off": "Disattiva backup",
|
"backup_controller_page_turn_off": "Disattiva backup",
|
||||||
"backup_controller_page_turn_on": "Attiva backup ",
|
"backup_controller_page_turn_on": "Attiva backup ",
|
||||||
"backup_controller_page_uploading_file_info": "Info sul file caricato",
|
"backup_controller_page_uploading_file_info": "Info sul file caricato",
|
||||||
"backup_err_only_album": "Non è possibile rimuovere l'unico album",
|
"backup_err_only_album": "Non è possibile rimuovere l'unico album",
|
||||||
"backup_info_card_assets": "Oggetti ",
|
"backup_info_card_assets": "oggetti ",
|
||||||
|
"cache_settings_album_thumbnails": "Anteprime pagine librerie ({} assets)",
|
||||||
|
"cache_settings_clear_cache_button": "Cancella cache",
|
||||||
|
"cache_settings_clear_cache_button_title": "Cancella cache app. Questo impatterà sulle prestazioni applicative fino a quando la cache non sarà rigenerata.",
|
||||||
|
"cache_settings_image_cache_size": "Dimensione cache foto ({} assets)",
|
||||||
|
"cache_settings_statistics_album": "Anteprime librerie",
|
||||||
|
"cache_settings_statistics_assets": "{} contenuti ({})",
|
||||||
|
"cache_settings_statistics_full": "Immagini complete",
|
||||||
|
"cache_settings_statistics_shared": "Anteprime album condivisi",
|
||||||
|
"cache_settings_statistics_thumbnail": "Anteprime",
|
||||||
|
"cache_settings_statistics_title": "Uso della cache",
|
||||||
|
"cache_settings_subtitle": "Controlla il comportamento della cache",
|
||||||
|
"cache_settings_thumbnail_size": "Dimensione cache anteprime ({} assets)",
|
||||||
|
"cache_settings_title": "Impostazioni della Cache",
|
||||||
"control_bottom_app_bar_delete": "Elimina",
|
"control_bottom_app_bar_delete": "Elimina",
|
||||||
|
"control_bottom_app_bar_share": "Condividi",
|
||||||
|
"create_album_page_untitled": "Senza titolo",
|
||||||
|
"create_shared_album_page_create": "Crea",
|
||||||
"create_shared_album_page_share": "Condividi",
|
"create_shared_album_page_share": "Condividi",
|
||||||
"create_shared_album_page_share_add_assets": "AGGIUNGI OGGETTI",
|
"create_shared_album_page_share_add_assets": "AGGIUNGI OGGETTI",
|
||||||
"create_shared_album_page_share_select_photos": "Seleziona foto",
|
"create_shared_album_page_share_select_photos": "Seleziona foto",
|
||||||
"daily_title_text_date": "E, dd MMM",
|
"daily_title_text_date": "E, dd MMM",
|
||||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||||
"date_format": "E, d LLL, y • hh:mm",
|
"date_format": "E, d LLL, y • hh:mm",
|
||||||
"delete_dialog_alert": "Questi oggetti saranno cancellati permanentemente da Immich e dal tuo device",
|
"delete_dialog_alert": "Questi oggetti saranno cancellati definitivamente da Immich e dal tuo device",
|
||||||
"delete_dialog_cancel": "Annulla",
|
"delete_dialog_cancel": "Annulla",
|
||||||
"delete_dialog_ok": "Elimina",
|
"delete_dialog_ok": "Elimina",
|
||||||
"delete_dialog_title": "Cancella in modo permanente ",
|
"delete_dialog_title": "Cancella definitivamente",
|
||||||
"exif_bottom_sheet_description": "Aggiungi una descrizione...",
|
"exif_bottom_sheet_description": "Aggiungi una descrizione...",
|
||||||
"exif_bottom_sheet_details": "DETTAGLI",
|
"exif_bottom_sheet_details": "DETTAGLI",
|
||||||
"exif_bottom_sheet_location": "POSIZIONE",
|
"exif_bottom_sheet_location": "POSIZIONE",
|
||||||
"login_form_button_text": "Accedi",
|
"library_page_albums": "Album",
|
||||||
|
"library_page_new_album": "Nuovo Album",
|
||||||
|
"login_form_button_text": "Login",
|
||||||
"login_form_email_hint": "tuaemail@email.com",
|
"login_form_email_hint": "tuaemail@email.com",
|
||||||
"login_form_endpoint_hint": "http://tuo-ip-del-server:port/api",
|
"login_form_endpoint_hint": "http://tuo-ip-del-server:port/api",
|
||||||
"login_form_endpoint_url": "URL del Server Endpoint",
|
"login_form_endpoint_url": "Server Endpoint URL",
|
||||||
"login_form_err_http": "Per favore specificare http:// o https://",
|
"login_form_err_http": "Per favore specificare http:// o https://",
|
||||||
"login_form_err_invalid_email": "Email non valida",
|
"login_form_err_invalid_email": "Email non valida",
|
||||||
"login_form_err_leading_whitespace": "Spazio bianco all'inizio ",
|
"login_form_err_leading_whitespace": "Spazio bianco all'inizio ",
|
||||||
@@ -74,33 +116,54 @@
|
|||||||
"login_form_save_login": "Rimani connesso ",
|
"login_form_save_login": "Rimani connesso ",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"profile_drawer_client_server_up_to_date": "Client e server sono aggiornati",
|
"profile_drawer_client_server_up_to_date": "Client e server sono aggiornati",
|
||||||
"profile_drawer_sign_out": "Esci",
|
"profile_drawer_settings": "Impostazioni ",
|
||||||
|
"profile_drawer_sign_out": "Logout",
|
||||||
"search_bar_hint": "Cerca le tue foto",
|
"search_bar_hint": "Cerca le tue foto",
|
||||||
"search_page_no_objects": "Nessuna Informazione relativa all'Oggetto Disponibile",
|
"search_page_no_objects": "Nessuna informazione relativa all'oggetto disponibile",
|
||||||
"search_page_no_places": "Nessun informazione sulla posizione ",
|
"search_page_no_places": "Nessun informazione sul luogo disponibile",
|
||||||
"search_page_places": "Luoghi",
|
"search_page_places": "Luoghi",
|
||||||
"search_page_things": "Oggetti",
|
"search_page_things": "Oggetti",
|
||||||
"search_result_page_new_search_hint": "Nuova ricerca ",
|
"search_result_page_new_search_hint": "Nuova ricerca ",
|
||||||
"select_additional_user_for_sharing_page_suggestions": "Suggerimenti ",
|
"select_additional_user_for_sharing_page_suggestions": "Suggerimenti ",
|
||||||
"select_user_for_sharing_page_err_album": "Fallito nel creare l'album ",
|
"select_user_for_sharing_page_err_album": "Errore nel creare l'album ",
|
||||||
"select_user_for_sharing_page_share_suggestions": "Suggerimenti",
|
"select_user_for_sharing_page_share_suggestions": "Suggerimenti",
|
||||||
|
"setting_notifications_notify_failures_grace_period": "Notifica caricamenti falliti in background: {}",
|
||||||
|
"setting_notifications_notify_hours": "{} Ore",
|
||||||
|
"setting_notifications_notify_immediately": "Immediatamente",
|
||||||
|
"setting_notifications_notify_minutes": "{} Minuti",
|
||||||
|
"setting_notifications_notify_never": "Mai",
|
||||||
|
"setting_notifications_subtitle": "Cambia le impostazioni di notifica",
|
||||||
|
"setting_notifications_title": "Notifiche",
|
||||||
|
"setting_pages_app_bar_settings": "Impostazioni",
|
||||||
"share_add": "Aggiungi",
|
"share_add": "Aggiungi",
|
||||||
"share_add_photos": "Aggiungi foto",
|
"share_add_photos": "Aggiungi foto",
|
||||||
"share_add_title": "Aggiungi un titolo ",
|
"share_add_title": "Aggiungi un titolo ",
|
||||||
"share_create_album": "Crea album",
|
"share_create_album": "Crea album",
|
||||||
"share_invite": "Invitare all'album ",
|
"share_dialog_preparing": "Preparo…",
|
||||||
|
"share_invite": "Invitare nell'album ",
|
||||||
"sharing_page_album": "Album condivisi",
|
"sharing_page_album": "Album condivisi",
|
||||||
"sharing_page_description": "Crea un album condiviso per condividere foto e video con gente nel tuo network",
|
"sharing_page_description": "Crea un album condiviso per condividere foto e video con persone nel tuo network",
|
||||||
"sharing_page_empty_list": "LISTA VUOTA",
|
"sharing_page_empty_list": "LISTA VUOTA",
|
||||||
"sharing_silver_appbar_create_shared_album": "Crea album condiviso",
|
"sharing_silver_appbar_create_shared_album": "Crea album condiviso",
|
||||||
"sharing_silver_appbar_share_partner": "Condividi con il partner",
|
"sharing_silver_appbar_share_partner": "Condividi con il partner",
|
||||||
|
"tab_controller_nav_library": "Libreria",
|
||||||
"tab_controller_nav_photos": "Foto",
|
"tab_controller_nav_photos": "Foto",
|
||||||
"tab_controller_nav_search": "Cerca",
|
"tab_controller_nav_search": "Cerca",
|
||||||
"tab_controller_nav_sharing": "Condividi",
|
"tab_controller_nav_sharing": "Condividi",
|
||||||
|
"theme_setting_asset_list_storage_indicator_title": "Mostra indicatore dello storage nei titoli dei contenuti",
|
||||||
|
"theme_setting_asset_list_tiles_per_row_title": "Numero di contenuti per riga ({})",
|
||||||
|
"theme_setting_dark_mode_switch": "Dark mode",
|
||||||
|
"theme_setting_image_viewer_quality_subtitle": "Cambia la qualità del dettaglio dell'immagine",
|
||||||
|
"theme_setting_image_viewer_quality_title": "Qualità immagine",
|
||||||
|
"theme_setting_system_theme_switch": "Automatico (Vai alle impostazioni di sistema)",
|
||||||
|
"theme_setting_theme_subtitle": "Scegli un'impostazione per il tema",
|
||||||
|
"theme_setting_theme_title": "Tema",
|
||||||
|
"theme_setting_three_stage_loading_subtitle": "Il caricamento in 3 stage aumenterà le performance di caricamento ma anche il consumo di banda",
|
||||||
|
"theme_setting_three_stage_loading_title": "Abilita il caricamento a tre stage",
|
||||||
"version_announcement_overlay_ack": "Riconosci ",
|
"version_announcement_overlay_ack": "Riconosci ",
|
||||||
"version_announcement_overlay_release_notes": "le note di rilascio ",
|
"version_announcement_overlay_release_notes": "note di rilascio ",
|
||||||
"version_announcement_overlay_text_1": "Ciao amico, c'è una nuova versione di",
|
"version_announcement_overlay_text_1": "Ciao amico, c'è una nuova versione di",
|
||||||
"version_announcement_overlay_text_2": "prova a controllare ",
|
"version_announcement_overlay_text_2": "per favore prenditi il tuo tempo per controllare il",
|
||||||
"version_announcement_overlay_text_3": "e verifica che il tuo docker-compose e il file .env siano aggiornati per impedire qualsiasi errore nella configurazione, specialmente se utilizzate WatchTower o altri strumenti per l'aggiornamento automatico delle immagini docker.",
|
"version_announcement_overlay_text_3": "e verifica che il tuo docker-compose e il file .env siano aggiornati per impedire qualsiasi errore nella configurazione, specialmente se utilizzate WatchTower o altri strumenti per l'aggiornamento automatico delle immagini docker.",
|
||||||
"version_announcement_overlay_title": "Nuova versione di server disponibile! \uD83C\uDF89"
|
"version_announcement_overlay_title": "Nuova versione del server disponibile! \uD83C\uDF89"
|
||||||
}
|
}
|
||||||
152
mobile/assets/i18n/ko-KR.json
Normal file
152
mobile/assets/i18n/ko-KR.json
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"album_info_card_backup_album_excluded": "제외됨",
|
||||||
|
"album_info_card_backup_album_included": "포함됨",
|
||||||
|
"album_viewer_appbar_share_delete": "앨범 삭제",
|
||||||
|
"album_viewer_appbar_share_err_delete": "앨범 삭제 실패",
|
||||||
|
"album_viewer_appbar_share_err_leave": "앨범에서 나가지 못했습니다",
|
||||||
|
"album_viewer_appbar_share_err_remove": "앨범에서 미디어를 제거하는 데 문제가 있습니다",
|
||||||
|
"album_viewer_appbar_share_err_title": "앨범 제목 변경 실패",
|
||||||
|
"album_viewer_appbar_share_leave": "앨범 나가기",
|
||||||
|
"album_viewer_appbar_share_remove": "앨범에서 제거",
|
||||||
|
"album_viewer_page_share_add_users": "사용자 추가",
|
||||||
|
"backup_album_selection_page_albums_device": "기기의 앨범({})",
|
||||||
|
"backup_album_selection_page_albums_tap": "포함하려면 탭하고 제외하려면 두 번 탭하세요",
|
||||||
|
"backup_album_selection_page_assets_scatter": "미디어파일은 여러 앨범에 분산될 수 있습니다. 따라서 백업 프로세스 중에 앨범에서 포함하거나 제외할 수 있습니다.",
|
||||||
|
"backup_album_selection_page_select_albums": "앨범 선택",
|
||||||
|
"backup_album_selection_page_selection_info": "선택 정보",
|
||||||
|
"backup_album_selection_page_total_assets": "총 미디어파일 수",
|
||||||
|
"backup_all": "모두",
|
||||||
|
"backup_background_service_default_notification": "새 미디어파일 확인중...",
|
||||||
|
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
|
||||||
|
"backup_background_service_in_progress_notification": "미디어파일 백업 중...",
|
||||||
|
"backup_background_service_current_upload_notification": "{} 업로드 중",
|
||||||
|
"backup_background_service_error_title": "백업 오류",
|
||||||
|
"backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...",
|
||||||
|
"backup_background_service_backup_failed_message": "미디어파일을 백업하지 못했습니다. 다시 시도하는 중...",
|
||||||
|
"backup_controller_page_albums": "백업대상",
|
||||||
|
"backup_controller_page_backup": "백업",
|
||||||
|
"backup_controller_page_backup_selected": "선택됨: ",
|
||||||
|
"backup_controller_page_backup_sub": "백업된 사진 및 비디오",
|
||||||
|
"backup_controller_page_background_description": "백그라운드 서비스를 켜서 앱을 열지 않고도 새 미디어파일을 자동으로 백업합니다.",
|
||||||
|
"backup_controller_page_background_wifi": "WiFi에서만",
|
||||||
|
"backup_controller_page_background_charging": "충전 중일 때만",
|
||||||
|
"backup_controller_page_background_is_on": "자동 백그라운드 백업이 켜져 있습니다",
|
||||||
|
"backup_controller_page_background_is_off": "자동 백그라운드 백업이 꺼져 있습니다",
|
||||||
|
"backup_controller_page_background_turn_on": "백그라운드 서비스 켜기",
|
||||||
|
"backup_controller_page_background_turn_off": "백그라운드 서비스 끄기",
|
||||||
|
"backup_controller_page_background_configure_error": "백그라운드 서비스를 구성하지 못했습니다",
|
||||||
|
"backup_controller_page_cancel": "취소",
|
||||||
|
"backup_controller_page_created": "생성일: {}",
|
||||||
|
"backup_controller_page_desc_backup": "새 미디어파일을 서버에 자동으로 업로드하려면 백업을 켜주세요.",
|
||||||
|
"backup_controller_page_excluded": "제외됨: ",
|
||||||
|
"backup_controller_page_failed": "실패함 ({})",
|
||||||
|
"backup_controller_page_filename": "파일 이름: {} [{}]",
|
||||||
|
"backup_controller_page_id": "ID: {}",
|
||||||
|
"backup_controller_page_info": "정보",
|
||||||
|
"backup_controller_page_none_selected": "선택되지 않음",
|
||||||
|
"backup_controller_page_remainder": "남은 백업파일",
|
||||||
|
"backup_controller_page_remainder_sub": "백업 대기중인 남은 사진 및 비디오",
|
||||||
|
"backup_controller_page_select": "선택",
|
||||||
|
"backup_controller_page_server_storage": "서버 저장소",
|
||||||
|
"backup_controller_page_start_backup": "백업 시작",
|
||||||
|
"backup_controller_page_status_off": "백업이 꺼져 있습니다",
|
||||||
|
"backup_controller_page_status_on": "백업이 켜져 있습니다",
|
||||||
|
"backup_controller_page_storage_format": "{}/{} 사용",
|
||||||
|
"backup_controller_page_to_backup": "백업할 앨범",
|
||||||
|
"backup_controller_page_total": "전체 백업대상",
|
||||||
|
"backup_controller_page_total_sub": "선택한 앨범의 모든 사진 및 비디오",
|
||||||
|
"backup_controller_page_turn_off": "백업 끄기",
|
||||||
|
"backup_controller_page_turn_on": "백업 켜기",
|
||||||
|
"backup_controller_page_uploading_file_info": "파일 정보 업로드 중",
|
||||||
|
"backup_err_only_album": "유일한 앨범은 제거할 수 없습니다",
|
||||||
|
"backup_info_card_assets": "미디어",
|
||||||
|
"control_bottom_app_bar_delete": "삭제",
|
||||||
|
"create_shared_album_page_share": "공유",
|
||||||
|
"create_shared_album_page_create": "만들기",
|
||||||
|
"create_shared_album_page_share_add_assets": "사진 추가",
|
||||||
|
"create_shared_album_page_share_select_photos": "사진 선택",
|
||||||
|
"daily_title_text_date": "E, M월 d일",
|
||||||
|
"daily_title_text_date_year": "E, M월 d일, yyyy",
|
||||||
|
"date_format": "yyyy년 M월 d일, EEEE • a h:mm",
|
||||||
|
"delete_dialog_alert": "이 항목은 Immich 및 휴대폰에서 영구적으로 삭제됩니다",
|
||||||
|
"delete_dialog_cancel": "취소",
|
||||||
|
"delete_dialog_ok": "삭제",
|
||||||
|
"delete_dialog_title": "영구적으로 삭제",
|
||||||
|
"exif_bottom_sheet_description": "설명 추가...",
|
||||||
|
"exif_bottom_sheet_details": "상세정보",
|
||||||
|
"exif_bottom_sheet_location": "위치",
|
||||||
|
"login_form_button_text": "로그인",
|
||||||
|
"login_form_email_hint": "youremail@email.com",
|
||||||
|
"login_form_endpoint_hint": "https://your-server-ip:port/api",
|
||||||
|
"login_form_endpoint_url": "서버 엔드포인트 URL",
|
||||||
|
"login_form_err_http": "엔드포인트는 http:// 또는 https://로 시작해야 합니다",
|
||||||
|
"login_form_err_invalid_email": "잘못된 이메일 형식입니다",
|
||||||
|
"login_form_err_leading_whitespace": "이메일 앞에 공백문자가 포함되어 있습니다",
|
||||||
|
"login_form_err_trailing_whitespace": "이메일 뒤에 공백문자가 포함되어 있습니다",
|
||||||
|
"login_form_failed_login": "로그인 오류, 서버 URL, 이메일 및 비밀번호를 확인하세요",
|
||||||
|
"login_form_label_email": "이메일",
|
||||||
|
"login_form_label_password": "비밀번호",
|
||||||
|
"login_form_password_hint": "비밀번호",
|
||||||
|
"login_form_save_login": "로그인상태 유지",
|
||||||
|
"monthly_title_text_date_format": "y년 M월",
|
||||||
|
"profile_drawer_client_server_up_to_date": "클라이언트와 서버가 최신 상태입니다",
|
||||||
|
"profile_drawer_sign_out": "로그아웃",
|
||||||
|
"profile_drawer_settings": "설정",
|
||||||
|
"search_bar_hint": "사진 검색",
|
||||||
|
"search_page_no_objects": "발견된 사물이\n없습니다",
|
||||||
|
"search_page_no_places": "발견된 장소가\n없습니다",
|
||||||
|
"search_page_places": "장소",
|
||||||
|
"search_page_things": "사물",
|
||||||
|
"search_result_page_new_search_hint": "새 검색",
|
||||||
|
"select_additional_user_for_sharing_page_suggestions": "초대 가능한 사용자 제안",
|
||||||
|
"select_user_for_sharing_page_err_album": "앨범 생성 실패",
|
||||||
|
"select_user_for_sharing_page_share_suggestions": "초대 가능한 사용자 제안",
|
||||||
|
"share_add": "추가",
|
||||||
|
"share_add_photos": "사진 추가",
|
||||||
|
"share_add_title": "새 앨범제목",
|
||||||
|
"share_create_album": "앨범 만들기",
|
||||||
|
"share_invite": "앨범에 초대",
|
||||||
|
"sharing_page_album": "공유앨범",
|
||||||
|
"sharing_page_description": "공유앨범을 만들어 다른 사용자들과 사진 및 비디오를 공유합니다.",
|
||||||
|
"sharing_page_empty_list": "공유앨범 없음",
|
||||||
|
"sharing_silver_appbar_create_shared_album": "공유앨범 만들기",
|
||||||
|
"sharing_silver_appbar_share_partner": "파트너와 공유",
|
||||||
|
"tab_controller_nav_photos": "사진",
|
||||||
|
"tab_controller_nav_search": "검색",
|
||||||
|
"tab_controller_nav_sharing": "공유",
|
||||||
|
"tab_controller_nav_library": "라이브러리",
|
||||||
|
"version_announcement_overlay_ack": "승인",
|
||||||
|
"version_announcement_overlay_release_notes": "릴리스 정보",
|
||||||
|
"version_announcement_overlay_text_1": "안녕하세요!",
|
||||||
|
"version_announcement_overlay_text_2": "앱에 새로운 업데이트가 있습니다!",
|
||||||
|
"version_announcement_overlay_text_3": "특히 WatchTower 또는 서버 응용 프로그램 자동 업데이트를 처리하는 메커니즘을 사용하는 경우 잘못된 구성을 방지하기 위해 docker-compose 및 .env 설정이 최신 상태인지 확인하세요.",
|
||||||
|
"version_announcement_overlay_title": "새 서버 버전 사용 가능 \uD83C\uDF89",
|
||||||
|
"album_thumbnail_card_item": "1개 항목",
|
||||||
|
"album_thumbnail_card_items": "{}개 항목",
|
||||||
|
"album_thumbnail_card_shared": " · 공유",
|
||||||
|
"library_page_albums": "앨범",
|
||||||
|
"library_page_new_album": "새 앨범",
|
||||||
|
"create_album_page_untitled": "제목없음",
|
||||||
|
"share_dialog_preparing": "준비중...",
|
||||||
|
"control_bottom_app_bar_share": "공유",
|
||||||
|
"setting_pages_app_bar_settings": "설정",
|
||||||
|
"theme_setting_theme_title": "테마",
|
||||||
|
"theme_setting_theme_subtitle": "앱테마 선택",
|
||||||
|
"theme_setting_system_theme_switch": "자동(시스템 설정에 따름)",
|
||||||
|
"theme_setting_dark_mode_switch": "다크모드",
|
||||||
|
"theme_setting_image_viewer_quality_title": "이미지 뷰어 품질",
|
||||||
|
"theme_setting_image_viewer_quality_subtitle": "디테일 이미지 뷰어 품질 조정",
|
||||||
|
"theme_setting_three_stage_loading_title": "3단계 로딩 활성화",
|
||||||
|
"theme_setting_three_stage_loading_subtitle": "이 기능은 로딩 성능을 향상시킬 수 있지만 훨씬 더 많은 데이터를 사용합니다.",
|
||||||
|
"asset_list_settings_title": "사진 배열",
|
||||||
|
"asset_list_settings_subtitle": "사진 배열 레이아웃 설정",
|
||||||
|
"theme_setting_asset_list_storage_indicator_title": "미디어 타일에 스토리지 싱크여부 표시",
|
||||||
|
"theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 미디어 수 ({})",
|
||||||
|
"setting_notifications_title": "알림",
|
||||||
|
"setting_notifications_subtitle": "알림 기본 설정 조정",
|
||||||
|
"setting_notifications_notify_failures_grace_period": "백그라운드 백업 실패 알림: {}",
|
||||||
|
"setting_notifications_notify_immediately": "즉시",
|
||||||
|
"setting_notifications_notify_minutes": "{}분 뒤",
|
||||||
|
"setting_notifications_notify_hours": "{}시간 뒤",
|
||||||
|
"setting_notifications_notify_never": "알리지 않음"
|
||||||
|
}
|
||||||
152
mobile/assets/i18n/nl-NL.json
Normal file
152
mobile/assets/i18n/nl-NL.json
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"album_info_card_backup_album_excluded": "UITGESLOTEN",
|
||||||
|
"album_info_card_backup_album_included": "INGESLOTEN",
|
||||||
|
"album_viewer_appbar_share_delete": "Verwijder album",
|
||||||
|
"album_viewer_appbar_share_err_delete": "Fout bij verwijderen album",
|
||||||
|
"album_viewer_appbar_share_err_leave": "Fout bij verlaten album",
|
||||||
|
"album_viewer_appbar_share_err_remove": "Er gaat iets mis bij het verwijderen van items uit het album",
|
||||||
|
"album_viewer_appbar_share_err_title": "Fout bij wijzigen album titel",
|
||||||
|
"album_viewer_appbar_share_leave": "Verlaat album",
|
||||||
|
"album_viewer_appbar_share_remove": "Verwijder uit album",
|
||||||
|
"album_viewer_page_share_add_users": "Voeg gebruiker toe",
|
||||||
|
"backup_album_selection_page_albums_device": "Albums op apparaat ({})",
|
||||||
|
"backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten",
|
||||||
|
"backup_album_selection_page_assets_scatter": "Items kunnen over verschillende albums verdeeld zijn, dus albums kunnen ingesloten of uitgesloten zijn van het backup proces.",
|
||||||
|
"backup_album_selection_page_select_albums": "Selecteer albums",
|
||||||
|
"backup_album_selection_page_selection_info": "Selectie info",
|
||||||
|
"backup_album_selection_page_total_assets": "Totaal unieke items",
|
||||||
|
"backup_all": "Alle",
|
||||||
|
"backup_background_service_default_notification": "Controleren op nieuw items…",
|
||||||
|
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
|
||||||
|
"backup_background_service_in_progress_notification": "Backuppen van items…",
|
||||||
|
"backup_background_service_current_upload_notification": "Uploaden {}",
|
||||||
|
"backup_background_service_error_title": "Backup fout",
|
||||||
|
"backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…",
|
||||||
|
"backup_background_service_backup_failed_message": "Fout bij backuppen items. Opnieuw proberen…",
|
||||||
|
"backup_controller_page_albums": "Backup Albums",
|
||||||
|
"backup_controller_page_backup": "Backup",
|
||||||
|
"backup_controller_page_backup_selected": "Geselecteerd: ",
|
||||||
|
"backup_controller_page_backup_sub": "Foto's en video's gebackupped",
|
||||||
|
"backup_controller_page_background_description": "Gebruik achtergrondservice om automatisch nieuwe items te uploaden naar server zonder de app te openen",
|
||||||
|
"backup_controller_page_background_wifi": "Alleen op WiFi",
|
||||||
|
"backup_controller_page_background_charging": "Alleen tijdens opladen",
|
||||||
|
"backup_controller_page_background_is_on": "Automatische achtergrond backup staat aan",
|
||||||
|
"backup_controller_page_background_is_off": "Automatische achtergrond backup staat uit",
|
||||||
|
"backup_controller_page_background_turn_on": "Zet achtergrondservice aan",
|
||||||
|
"backup_controller_page_background_turn_off": "Zet achtergrondservice uit",
|
||||||
|
"backup_controller_page_background_configure_error": "Achtergrondservice configuratie mislukt",
|
||||||
|
"backup_controller_page_cancel": "Annuleren",
|
||||||
|
"backup_controller_page_created": "Gemaakt op: {}",
|
||||||
|
"backup_controller_page_desc_backup": "Configureer backup om automatisch nieuwe items te uploaden naar server.",
|
||||||
|
"backup_controller_page_excluded": "Uitgezonderd: ",
|
||||||
|
"backup_controller_page_failed": "Mislukt ({})",
|
||||||
|
"backup_controller_page_filename": "Bestandsnaam: {} [{}]",
|
||||||
|
"backup_controller_page_id": "ID: {}",
|
||||||
|
"backup_controller_page_info": "Backup informatie",
|
||||||
|
"backup_controller_page_none_selected": "Geen geselecteerd",
|
||||||
|
"backup_controller_page_remainder": "Rest",
|
||||||
|
"backup_controller_page_remainder_sub": "Overgebleven foto's en video's om te backuppen uit selectie",
|
||||||
|
"backup_controller_page_select": "Selecteer",
|
||||||
|
"backup_controller_page_server_storage": "Server Opslag",
|
||||||
|
"backup_controller_page_start_backup": "Start Backup",
|
||||||
|
"backup_controller_page_status_off": "Backup staat uit",
|
||||||
|
"backup_controller_page_status_on": "Backup staat aan",
|
||||||
|
"backup_controller_page_storage_format": "{} van {} gebruikt",
|
||||||
|
"backup_controller_page_to_backup": "Albums om te backuppen",
|
||||||
|
"backup_controller_page_total": "Totaal",
|
||||||
|
"backup_controller_page_total_sub": "Alle unieke foto's en video's uit geselecteerde albums",
|
||||||
|
"backup_controller_page_turn_off": "Backup uitzetten",
|
||||||
|
"backup_controller_page_turn_on": "Backup aanzetten",
|
||||||
|
"backup_controller_page_uploading_file_info": "Bestandsgegevens uploaden",
|
||||||
|
"backup_err_only_album": "Kan niet alleen het album verwijderen",
|
||||||
|
"backup_info_card_assets": "items",
|
||||||
|
"control_bottom_app_bar_delete": "Verwijderen",
|
||||||
|
"create_shared_album_page_share": "Delen",
|
||||||
|
"create_shared_album_page_create": "Aanmaken",
|
||||||
|
"create_shared_album_page_share_add_assets": "VOEG FOTO'S TOE",
|
||||||
|
"create_shared_album_page_share_select_photos": "Selecteer Foto's",
|
||||||
|
"daily_title_text_date": "E, MMM dd",
|
||||||
|
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||||
|
"date_format": "E, LLL d, y • h:mm a",
|
||||||
|
"delete_dialog_alert": "Deze items zullen permanent verwijderd worden van Immich en je apparaat",
|
||||||
|
"delete_dialog_cancel": "Annuleren",
|
||||||
|
"delete_dialog_ok": "Verwijderen",
|
||||||
|
"delete_dialog_title": "Verwijder permanent",
|
||||||
|
"exif_bottom_sheet_description": "Voeg beschrijving toe...",
|
||||||
|
"exif_bottom_sheet_details": "DETAILS",
|
||||||
|
"exif_bottom_sheet_location": "LOCATIE",
|
||||||
|
"login_form_button_text": "Login",
|
||||||
|
"login_form_email_hint": "jouwemail@email.com",
|
||||||
|
"login_form_endpoint_hint": "http://jouw-server-ip:port/api",
|
||||||
|
"login_form_endpoint_url": "Server URL",
|
||||||
|
"login_form_err_http": "Voer http:// of https:// in",
|
||||||
|
"login_form_err_invalid_email": "Ongeldige Email",
|
||||||
|
"login_form_err_leading_whitespace": "Spatie aan het begin",
|
||||||
|
"login_form_err_trailing_whitespace": "Spatie aan het eind",
|
||||||
|
"login_form_failed_login": "Fout bij inloggen, controleer server url, email en wachtwoord",
|
||||||
|
"login_form_label_email": "Email",
|
||||||
|
"login_form_label_password": "Wachtwoord",
|
||||||
|
"login_form_password_hint": "wachtwoord",
|
||||||
|
"login_form_save_login": "Ingelogd blijven",
|
||||||
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
|
"profile_drawer_client_server_up_to_date": "Client en Server zijn up-to-date",
|
||||||
|
"profile_drawer_sign_out": "Uitloggen",
|
||||||
|
"profile_drawer_settings": "Instellingen",
|
||||||
|
"search_bar_hint": "Zoek je foto's",
|
||||||
|
"search_page_no_objects": "Geen object gegevens beschikbaar",
|
||||||
|
"search_page_no_places": "Geen locatie gegevens beschikbaar",
|
||||||
|
"search_page_places": "Plaatsen",
|
||||||
|
"search_page_things": "Dingen",
|
||||||
|
"search_result_page_new_search_hint": "Nieuw resultaat",
|
||||||
|
"select_additional_user_for_sharing_page_suggestions": "Suggesties",
|
||||||
|
"select_user_for_sharing_page_err_album": "Album aanmaken mislukt",
|
||||||
|
"select_user_for_sharing_page_share_suggestions": "Suggesties",
|
||||||
|
"share_add": "Toevoegen",
|
||||||
|
"share_add_photos": "Foto's toevoegen",
|
||||||
|
"share_add_title": "Titel toevoegen",
|
||||||
|
"share_create_album": "Album aanmaken",
|
||||||
|
"share_invite": "Uitnodigen voor album",
|
||||||
|
"sharing_page_album": "Gedeelde albums",
|
||||||
|
"sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.",
|
||||||
|
"sharing_page_empty_list": "LEGE LIJST",
|
||||||
|
"sharing_silver_appbar_create_shared_album": "Maak gedeeld album",
|
||||||
|
"sharing_silver_appbar_share_partner": "Delen met partner",
|
||||||
|
"tab_controller_nav_photos": "Foto's",
|
||||||
|
"tab_controller_nav_search": "Zoeken",
|
||||||
|
"tab_controller_nav_sharing": "Delen",
|
||||||
|
"tab_controller_nav_library": "Bibliotheek",
|
||||||
|
"version_announcement_overlay_ack": "Bevestig",
|
||||||
|
"version_announcement_overlay_release_notes": "release opmerkingen",
|
||||||
|
"version_announcement_overlay_text_1": "Er is een nieuwe versie beschikbaar van",
|
||||||
|
"version_announcement_overlay_text_2": "neem je tijd en bezoek de ",
|
||||||
|
"version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je server automatisch configureert.",
|
||||||
|
"version_announcement_overlay_title": "Nieuwe server versie beschikbaar \uD83C\uDF89",
|
||||||
|
"album_thumbnail_card_item": "1 item",
|
||||||
|
"album_thumbnail_card_items": "{} items",
|
||||||
|
"album_thumbnail_card_shared": " · Gedeeld",
|
||||||
|
"library_page_albums": "Albums",
|
||||||
|
"library_page_new_album": "Nieuw album",
|
||||||
|
"create_album_page_untitled": "Naamloos",
|
||||||
|
"share_dialog_preparing": "Voorbereiden...",
|
||||||
|
"control_bottom_app_bar_share": "Delen",
|
||||||
|
"setting_pages_app_bar_settings": "Instellingen",
|
||||||
|
"theme_setting_theme_title": "Thema",
|
||||||
|
"theme_setting_theme_subtitle": "Kies de thema instelling van de app",
|
||||||
|
"theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)",
|
||||||
|
"theme_setting_dark_mode_switch": "Donkere modus",
|
||||||
|
"theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit",
|
||||||
|
"theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave",
|
||||||
|
"theme_setting_three_stage_loading_title": "Drie-laags laden inschakelen",
|
||||||
|
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||||
|
"asset_list_settings_title": "Foto Grid",
|
||||||
|
"asset_list_settings_subtitle": "Foto grid layout instellingen",
|
||||||
|
"theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels",
|
||||||
|
"theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})",
|
||||||
|
"setting_notifications_title": "Notificaties",
|
||||||
|
"setting_notifications_subtitle": "Werk je notificatievoorkeuren bij",
|
||||||
|
"setting_notifications_notify_failures_grace_period": "Melding achtergrond backup fouten: {}",
|
||||||
|
"setting_notifications_notify_immediately": "meteen",
|
||||||
|
"setting_notifications_notify_minutes": "{} minuten",
|
||||||
|
"setting_notifications_notify_hours": "{} uur",
|
||||||
|
"setting_notifications_notify_never": "nooit"
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"backup_album_selection_page_total_assets": "Total de recursos exclusivos",
|
"backup_album_selection_page_total_assets": "Total de recursos exclusivos",
|
||||||
"backup_all": "Todos",
|
"backup_all": "Todos",
|
||||||
"backup_background_service_default_notification": "Checking for new assets…",
|
"backup_background_service_default_notification": "Checking for new assets…",
|
||||||
"backup_background_service_disable_battery_optimizations": "Por favor, desabilite a otimização da bateria para Immich para habilitar o backup em segundo plano",
|
|
||||||
"backup_background_service_upload_failure_notification": "Falha ao carregar {}",
|
"backup_background_service_upload_failure_notification": "Falha ao carregar {}",
|
||||||
"backup_background_service_in_progress_notification": "Fazendo backup de seus ativos…",
|
"backup_background_service_in_progress_notification": "Fazendo backup de seus ativos…",
|
||||||
"backup_background_service_current_upload_notification": "Enviando {}",
|
"backup_background_service_current_upload_notification": "Enviando {}",
|
||||||
|
|||||||
@@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>9.0</string>
|
<string>11.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/wakelock/ios"
|
:path: ".symlinks/plugins/wakelock/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||||
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
|
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
|
||||||
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
||||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||||
|
|||||||
@@ -360,11 +360,11 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 40;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -495,11 +495,11 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 40;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -522,11 +522,11 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 40;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.21.0</string>
|
<string>1.30.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>40</string>
|
<string>62</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true />
|
<true />
|
||||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||||
@@ -92,7 +92,10 @@
|
|||||||
<string>it</string>
|
<string>it</string>
|
||||||
<string>fi</string>
|
<string>fi</string>
|
||||||
<string>ja</string>
|
<string>ja</string>
|
||||||
|
<string>ko</string>
|
||||||
|
<string>nl</string>
|
||||||
<string>pl</string>
|
<string>pl</string>
|
||||||
|
<string>pt</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.25.0"
|
version_number: "1.30.2"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -5,32 +5,32 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000205">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.360401">
|
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.012696">
|
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.378836">
|
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
|
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="98.18403">
|
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ const String userSettingInfoBox = "immichUserSettingInfoBox";
|
|||||||
|
|
||||||
// Background backup Info
|
// Background backup Info
|
||||||
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
|
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
|
||||||
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
|
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
|
||||||
|
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
|
||||||
|
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ const List<Locale> locales = [
|
|||||||
Locale('fr', 'FR'),
|
Locale('fr', 'FR'),
|
||||||
Locale('it', 'IT'),
|
Locale('it', 'IT'),
|
||||||
Locale('ja', 'JP'),
|
Locale('ja', 'JP'),
|
||||||
|
Locale('nl', 'NL'),
|
||||||
Locale('pl', 'PL'),
|
Locale('pl', 'PL'),
|
||||||
Locale('pt', 'PR')
|
Locale('pt', 'PR'),
|
||||||
|
Locale('ko', 'KR'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const String translationsPath = 'assets/i18n';
|
const String translationsPath = 'assets/i18n';
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/routing/router.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:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
const AlbumThumbnailCard({
|
const AlbumThumbnailCard({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.album,
|
required this.album,
|
||||||
required this.cacheService,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final AlbumResponseDto album;
|
final AlbumResponseDto album;
|
||||||
final CacheService cacheService;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
|
||||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
@@ -25,7 +24,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final cacheKey = useState(1);
|
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||||
@@ -123,7 +121,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(border: drawBorderColor()),
|
decoration: BoxDecoration(border: drawBorderColor()),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
cacheKey: asset.id,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 300,
|
height: 300,
|
||||||
memCacheHeight: 200,
|
memCacheHeight: 200,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class SelectionThumbnailImage extends HookConsumerWidget {
|
class SelectionThumbnailImage extends HookConsumerWidget {
|
||||||
@@ -15,10 +15,8 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final cacheKey = useState(1);
|
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl =
|
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
|
|
||||||
var selectedAsset =
|
var selectedAsset =
|
||||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||||
var newAssetsForAlbum =
|
var newAssetsForAlbum =
|
||||||
@@ -113,7 +111,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(border: drawBorderColor()),
|
decoration: BoxDecoration(border: drawBorderColor()),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
cacheKey: asset.id,
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 150,
|
height: 150,
|
||||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
|
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
@@ -15,8 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final cacheKey = useState(1);
|
|
||||||
|
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -26,7 +23,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
cacheKey: asset.id,
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 500,
|
height: 500,
|
||||||
memCacheHeight: 500,
|
memCacheHeight: 500,
|
||||||
|
|||||||
@@ -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/providers/album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
|
||||||
|
|
||||||
class LibraryPage extends HookConsumerWidget {
|
class LibraryPage extends HookConsumerWidget {
|
||||||
const LibraryPage({Key? key}) : super(key: key);
|
const LibraryPage({Key? key}) : super(key: key);
|
||||||
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider);
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
_buildCreateAlbumButton(),
|
_buildCreateAlbumButton(),
|
||||||
for (var album in albums)
|
for (var album in albums)
|
||||||
AlbumThumbnailCard(
|
AlbumThumbnailCard(
|
||||||
cacheService: cacheService,
|
|
||||||
album: album,
|
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/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/routing/router.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:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
memCacheHeight: 200,
|
memCacheHeight: 200,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheManager:
|
|
||||||
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
|
||||||
imageUrl: getAlbumThumbnailUrl(album),
|
imageUrl: getAlbumThumbnailUrl(album),
|
||||||
cacheKey: album.albumThumbnailAssetId,
|
cacheKey: album.albumThumbnailAssetId,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
@@ -95,12 +91,12 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 5.0, bottom: 5),
|
padding: const EdgeInsets.only(left: 5.0, bottom: 5),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.offline_share_outlined,
|
Icons.offline_share_outlined,
|
||||||
size: 50,
|
size: 50,
|
||||||
// color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||||
Text(
|
Text(
|
||||||
DateFormat('date_format'.tr()).format(
|
DateFormat('date_format'.tr()).format(
|
||||||
assetDetail.exifInfo!.dateTimeOriginal!,
|
assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[400],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
@@ -11,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
bool _zoomedIn = false;
|
bool _zoomedIn = false;
|
||||||
|
|
||||||
static const int swipeThreshold = 100;
|
static const int swipeThreshold = 100;
|
||||||
|
late CachedNetworkImageProvider fullProvider;
|
||||||
|
late CachedNetworkImageProvider previewProvider;
|
||||||
|
late CachedNetworkImageProvider thumbnailProvider;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -55,19 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
widget.isZoomedFunction();
|
widget.isZoomedFunction();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fireStartLoadingEvent() {
|
CachedNetworkImageProvider _authorizedImageProvider(
|
||||||
widget.onLoadingStart();
|
String url,
|
||||||
}
|
String cacheKey,
|
||||||
|
) {
|
||||||
void _fireFinishedLoadingEvent() {
|
|
||||||
widget.onLoadingCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
CachedNetworkImageProvider _authorizedImageProvider(String url) {
|
|
||||||
return CachedNetworkImageProvider(
|
return CachedNetworkImageProvider(
|
||||||
url,
|
url,
|
||||||
headers: {"Authorization": widget.authToken},
|
headers: {"Authorization": widget.authToken},
|
||||||
cacheKey: url,
|
cacheKey: cacheKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (newStatus != _RemoteImageStatus.full) {
|
|
||||||
_fireStartLoadingEvent();
|
|
||||||
} else {
|
|
||||||
_fireFinishedLoadingEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = newStatus;
|
_status = newStatus;
|
||||||
_imageProvider = provider;
|
_imageProvider = provider;
|
||||||
@@ -101,8 +92,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadImages() {
|
void _loadImages() {
|
||||||
CachedNetworkImageProvider thumbnailProvider =
|
thumbnailProvider = _authorizedImageProvider(
|
||||||
_authorizedImageProvider(widget.thumbnailUrl);
|
widget.thumbnailUrl,
|
||||||
|
widget.cacheKey,
|
||||||
|
);
|
||||||
_imageProvider = thumbnailProvider;
|
_imageProvider = thumbnailProvider;
|
||||||
|
|
||||||
thumbnailProvider.resolve(const ImageConfiguration()).addListener(
|
thumbnailProvider.resolve(const ImageConfiguration()).addListener(
|
||||||
@@ -115,8 +108,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (widget.previewUrl != null) {
|
if (widget.previewUrl != null) {
|
||||||
CachedNetworkImageProvider previewProvider =
|
previewProvider = _authorizedImageProvider(
|
||||||
_authorizedImageProvider(widget.previewUrl!);
|
widget.previewUrl!,
|
||||||
|
"${widget.cacheKey}_previewStage",
|
||||||
|
);
|
||||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||||
_performStateTransition(_RemoteImageStatus.preview, previewProvider);
|
_performStateTransition(_RemoteImageStatus.preview, previewProvider);
|
||||||
@@ -124,8 +119,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
CachedNetworkImageProvider fullProvider =
|
fullProvider = _authorizedImageProvider(
|
||||||
_authorizedImageProvider(widget.imageUrl);
|
widget.imageUrl,
|
||||||
|
"${widget.cacheKey}_fullStage",
|
||||||
|
);
|
||||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||||
_performStateTransition(_RemoteImageStatus.full, fullProvider);
|
_performStateTransition(_RemoteImageStatus.full, fullProvider);
|
||||||
@@ -135,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_loadImages();
|
|
||||||
super.initState();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,16 +163,14 @@ class RemotePhotoView extends StatefulWidget {
|
|||||||
required this.onSwipeDown,
|
required this.onSwipeDown,
|
||||||
required this.onSwipeUp,
|
required this.onSwipeUp,
|
||||||
this.previewUrl,
|
this.previewUrl,
|
||||||
required this.onLoadingCompleted,
|
required this.cacheKey,
|
||||||
required this.onLoadingStart,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final String thumbnailUrl;
|
final String thumbnailUrl;
|
||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
final String authToken;
|
final String authToken;
|
||||||
final String? previewUrl;
|
final String? previewUrl;
|
||||||
final Function onLoadingCompleted;
|
final String cacheKey;
|
||||||
final Function onLoadingStart;
|
|
||||||
|
|
||||||
final void Function() onSwipeDown;
|
final void Function() onSwipeDown;
|
||||||
final void Function() onSwipeUp;
|
final void Function() onSwipeUp;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -12,7 +10,7 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||||||
required this.onMoreInfoPressed,
|
required this.onMoreInfoPressed,
|
||||||
required this.onDownloadPressed,
|
required this.onDownloadPressed,
|
||||||
required this.onSharePressed,
|
required this.onSharePressed,
|
||||||
this.loading = false
|
this.loading = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
@@ -28,42 +26,37 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||||||
return AppBar(
|
return AppBar(
|
||||||
foregroundColor: Colors.grey[100],
|
foregroundColor: Colors.grey[100],
|
||||||
toolbarHeight: 60,
|
toolbarHeight: 60,
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.transparent,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AutoRouter.of(context).pop();
|
AutoRouter.of(context).pop();
|
||||||
},
|
},
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.arrow_back_ios_new_rounded,
|
Icons.arrow_back_ios_new_rounded,
|
||||||
size: 20.0,
|
size: 20.0,
|
||||||
|
color: Colors.grey[200],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (loading) Center(
|
if (loading)
|
||||||
child: Container(
|
Center(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 15.0),
|
child: Container(
|
||||||
width: iconSize,
|
margin: const EdgeInsets.symmetric(horizontal: 15.0),
|
||||||
height: iconSize,
|
width: iconSize,
|
||||||
child: const CircularProgressIndicator(strokeWidth: 2.0),
|
height: iconSize,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2.0),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
) ,
|
|
||||||
IconButton(
|
IconButton(
|
||||||
iconSize: iconSize,
|
iconSize: iconSize,
|
||||||
splashRadius: iconSize,
|
splashRadius: iconSize,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
onDownloadPressed();
|
onDownloadPressed();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.cloud_download_rounded),
|
icon: Icon(
|
||||||
),
|
Icons.cloud_download_rounded,
|
||||||
IconButton(
|
color: Colors.grey[200],
|
||||||
iconSize: iconSize,
|
),
|
||||||
splashRadius: iconSize,
|
|
||||||
onPressed: () {
|
|
||||||
log("favorite");
|
|
||||||
},
|
|
||||||
icon: asset.isFavorite
|
|
||||||
? const Icon(Icons.favorite_rounded)
|
|
||||||
: const Icon(Icons.favorite_border_rounded),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
iconSize: iconSize,
|
iconSize: iconSize,
|
||||||
@@ -71,7 +64,10 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
onSharePressed();
|
onSharePressed();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.share),
|
icon: Icon(
|
||||||
|
Icons.share,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
iconSize: iconSize,
|
iconSize: iconSize,
|
||||||
@@ -79,7 +75,10 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
onMoreInfoPressed();
|
onMoreInfoPressed();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.more_horiz_rounded),
|
icon: Icon(
|
||||||
|
Icons.more_horiz_rounded,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,8 +121,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||||
isZoomedFunction: isZoomedMethod,
|
isZoomedFunction: isZoomedMethod,
|
||||||
isZoomedListener: isZoomedListener,
|
isZoomedListener: isZoomedListener,
|
||||||
onLoadingCompleted: () => {},
|
|
||||||
onLoadingStart: () => {},
|
|
||||||
asset: assetList[index],
|
asset: assetList[index],
|
||||||
heroTag: assetList[index].id,
|
heroTag: assetList[index].id,
|
||||||
threeStageLoading: threeStageLoading.value,
|
threeStageLoading: threeStageLoading.value,
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
final String authToken;
|
final String authToken;
|
||||||
final ValueNotifier<bool> isZoomedListener;
|
final ValueNotifier<bool> isZoomedListener;
|
||||||
final void Function() isZoomedFunction;
|
final void Function() isZoomedFunction;
|
||||||
final void Function() onLoadingCompleted;
|
|
||||||
final void Function() onLoadingStart;
|
|
||||||
final bool threeStageLoading;
|
final bool threeStageLoading;
|
||||||
|
|
||||||
ImageViewerPage({
|
ImageViewerPage({
|
||||||
@@ -29,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
required this.authToken,
|
required this.authToken,
|
||||||
required this.isZoomedFunction,
|
required this.isZoomedFunction,
|
||||||
required this.isZoomedListener,
|
required this.isZoomedListener,
|
||||||
required this.onLoadingCompleted,
|
|
||||||
required this.onLoadingStart,
|
|
||||||
required this.threeStageLoading,
|
required this.threeStageLoading,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@@ -73,6 +69,7 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
tag: heroTag,
|
tag: heroTag,
|
||||||
child: RemotePhotoView(
|
child: RemotePhotoView(
|
||||||
thumbnailUrl: getThumbnailUrl(asset),
|
thumbnailUrl: getThumbnailUrl(asset),
|
||||||
|
cacheKey: asset.id,
|
||||||
imageUrl: getImageUrl(asset),
|
imageUrl: getImageUrl(asset),
|
||||||
previewUrl: threeStageLoading
|
previewUrl: threeStageLoading
|
||||||
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
|
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
|
||||||
@@ -82,8 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
isZoomedListener: isZoomedListener,
|
isZoomedListener: isZoomedListener,
|
||||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||||
onSwipeUp: () => showInfo(),
|
onSwipeUp: () => showInfo(),
|
||||||
onLoadingCompleted: onLoadingCompleted,
|
|
||||||
onLoadingStart: onLoadingStart,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
|||||||
_createChewieController() {
|
_createChewieController() {
|
||||||
chewieController = ChewieController(
|
chewieController = ChewieController(
|
||||||
showOptions: true,
|
showOptions: true,
|
||||||
showControlsOnInitialize: true,
|
showControlsOnInitialize: false,
|
||||||
videoPlayerController: videoPlayerController,
|
videoPlayerController: videoPlayerController,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
autoInitialize: true,
|
autoInitialize: true,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
|||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -33,7 +32,6 @@ class BackgroundService {
|
|||||||
MethodChannel('immich/foregroundChannel');
|
MethodChannel('immich/foregroundChannel');
|
||||||
static const MethodChannel _backgroundChannel =
|
static const MethodChannel _backgroundChannel =
|
||||||
MethodChannel('immich/backgroundChannel');
|
MethodChannel('immich/backgroundChannel');
|
||||||
bool _isForegroundInitialized = false;
|
|
||||||
bool _isBackgroundInitialized = false;
|
bool _isBackgroundInitialized = false;
|
||||||
CancellationToken? _cancellationToken;
|
CancellationToken? _cancellationToken;
|
||||||
bool _canceledBySystem = false;
|
bool _canceledBySystem = false;
|
||||||
@@ -43,32 +41,34 @@ class BackgroundService {
|
|||||||
ReceivePort? _rp;
|
ReceivePort? _rp;
|
||||||
bool _errorGracePeriodExceeded = true;
|
bool _errorGracePeriodExceeded = true;
|
||||||
|
|
||||||
bool get isForegroundInitialized {
|
|
||||||
return _isForegroundInitialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isBackgroundInitialized {
|
bool get isBackgroundInitialized {
|
||||||
return _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
|
/// Ensures that the background service is enqueued if enabled in settings
|
||||||
Future<bool> resumeServiceIfEnabled() async {
|
Future<bool> resumeServiceIfEnabled() async {
|
||||||
return await isBackgroundBackupEnabled() &&
|
return await isBackgroundBackupEnabled() && await enableService();
|
||||||
await startService(keepExisting: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues the background service
|
/// Enqueues the background service
|
||||||
Future<bool> startService({
|
Future<bool> enableService({bool immediate = false}) async {
|
||||||
bool immediate = false,
|
if (!Platform.isAndroid) {
|
||||||
bool keepExisting = false,
|
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 requireUnmetered = true,
|
||||||
bool requireCharging = false,
|
bool requireCharging = false,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -76,14 +76,9 @@ class BackgroundService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
final String title =
|
|
||||||
"backup_background_service_default_notification".tr();
|
|
||||||
final bool ok = await _foregroundChannel.invokeMethod(
|
final bool ok = await _foregroundChannel.invokeMethod(
|
||||||
'start',
|
'configure',
|
||||||
[immediate, keepExisting, requireUnmetered, requireCharging, title],
|
[requireUnmetered, requireCharging],
|
||||||
);
|
);
|
||||||
return ok;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,15 +87,12 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels the background service (if currently running) and removes it from work queue
|
/// Cancels the background service (if currently running) and removes it from work queue
|
||||||
Future<bool> stopService() async {
|
Future<bool> disableService() async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
final ok = await _foregroundChannel.invokeMethod('stop');
|
|
||||||
return ok;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
@@ -113,30 +105,20 @@ class BackgroundService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens an activity to let the user disable battery optimizations for Immich
|
/// Returns `true` if battery optimizations are disabled
|
||||||
Future<bool> disableBatteryOptimizations() async {
|
Future<bool> isIgnoringBatteryOptimizations() async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
return await _foregroundChannel
|
||||||
await _initialize();
|
.invokeMethod('isIgnoringBatteryOptimizations');
|
||||||
}
|
|
||||||
final String message =
|
|
||||||
"backup_background_service_disable_battery_optimizations".tr();
|
|
||||||
return await _foregroundChannel.invokeMethod(
|
|
||||||
'disableBatteryOptimizations',
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -191,7 +173,8 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"[_clearErrorNotifications] failed to communicate with plugin");
|
"[_clearErrorNotifications] failed to communicate with plugin",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -293,18 +276,11 @@ class BackgroundService {
|
|||||||
try {
|
try {
|
||||||
final bool hasAccess = await acquireLock();
|
final bool hasAccess = await acquireLock();
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
debugPrint("[_callHandler] could acquire lock, exiting");
|
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await translationsLoaded;
|
await translationsLoaded;
|
||||||
final bool ok = await _onAssetsChanged();
|
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;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
@@ -347,6 +323,31 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
|
||||||
|
do {
|
||||||
|
final bool backupOk = await _runBackup(backupService, 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,
|
||||||
|
HiveBackupAlbums backupAlbumInfo,
|
||||||
|
) async {
|
||||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
@@ -386,10 +387,6 @@ class BackgroundService {
|
|||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
_clearErrorNotifications();
|
_clearErrorNotifications();
|
||||||
await box.put(
|
|
||||||
backupInfoKey,
|
|
||||||
backupAlbumInfo,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "backup_background_service_error_title".tr(),
|
title: "backup_background_service_error_title".tr(),
|
||||||
@@ -451,6 +448,7 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||||
|
@pragma('vm:entry-point')
|
||||||
void _nativeEntry() {
|
void _nativeEntry() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
BackgroundService backgroundService = BackgroundService();
|
BackgroundService backgroundService = BackgroundService();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class AvailableAlbum {
|
|||||||
|
|
||||||
String get name => albumEntity.name;
|
String get name => albumEntity.name;
|
||||||
|
|
||||||
int get assetCount => albumEntity.assetCount;
|
Future<int> get assetCount => albumEntity.assetCountAsync;
|
||||||
|
|
||||||
String get id => albumEntity.id;
|
String get id => albumEntity.id;
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
bool? requireWifi,
|
bool? requireWifi,
|
||||||
bool? requireCharging,
|
bool? requireCharging,
|
||||||
required void Function(String msg) onError,
|
required void Function(String msg) onError,
|
||||||
|
required void Function() onBatteryInfo,
|
||||||
}) async {
|
}) async {
|
||||||
assert(enabled != null || requireWifi != null || requireCharging != null);
|
assert(enabled != null || requireWifi != null || requireCharging != null);
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
@@ -130,15 +131,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (state.backgroundBackup) {
|
if (state.backgroundBackup) {
|
||||||
|
bool success = true;
|
||||||
if (!wasEnabled) {
|
if (!wasEnabled) {
|
||||||
await _backgroundService.disableBatteryOptimizations();
|
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
||||||
|
onBatteryInfo();
|
||||||
|
}
|
||||||
|
success &= await _backgroundService.enableService(immediate: true);
|
||||||
}
|
}
|
||||||
final bool success = await _backgroundService.stopService() &&
|
success &= success &&
|
||||||
await _backgroundService.startService(
|
await _backgroundService.configureService(
|
||||||
requireUnmetered: state.backupRequireWifi,
|
requireUnmetered: state.backupRequireWifi,
|
||||||
requireCharging: state.backupRequireCharging,
|
requireCharging: state.backupRequireCharging,
|
||||||
);
|
);
|
||||||
if (!success) {
|
if (success) {
|
||||||
|
await Hive.box(backgroundBackupInfoBox)
|
||||||
|
.put(backupRequireWifi, state.backupRequireWifi);
|
||||||
|
await Hive.box(backgroundBackupInfoBox)
|
||||||
|
.put(backupRequireCharging, state.backupRequireCharging);
|
||||||
|
} else {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
backgroundBackup: wasEnabled,
|
backgroundBackup: wasEnabled,
|
||||||
backupRequireWifi: wasWifi,
|
backupRequireWifi: wasWifi,
|
||||||
@@ -147,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
onError("backup_controller_page_background_configure_error");
|
onError("backup_controller_page_background_configure_error");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final bool success = await _backgroundService.stopService();
|
final bool success = await _backgroundService.disableService();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||||
onError("backup_controller_page_background_configure_error");
|
onError("backup_controller_page_background_configure_error");
|
||||||
@@ -173,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
for (AssetPathEntity album in albums) {
|
for (AssetPathEntity album in albums) {
|
||||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
var assetList =
|
var assetCountInAlbum = await album.assetCountAsync;
|
||||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
if (assetCountInAlbum > 0) {
|
||||||
|
var assetList =
|
||||||
|
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
|
||||||
|
|
||||||
if (assetList.isNotEmpty) {
|
if (assetList.isNotEmpty) {
|
||||||
var thumbnailAsset = assetList.first;
|
var thumbnailAsset = assetList.first;
|
||||||
var thumbnailData = await thumbnailAsset
|
var thumbnailData = await thumbnailAsset
|
||||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||||
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
availableAlbum =
|
||||||
|
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableAlbums.add(availableAlbum);
|
||||||
}
|
}
|
||||||
|
|
||||||
availableAlbums.add(availableAlbum);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(availableAlbums: availableAlbums);
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
@@ -286,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
for (var album in state.selectedBackupAlbums) {
|
for (var album in state.selectedBackupAlbums) {
|
||||||
var assets = await album.albumEntity
|
var assets = await album.albumEntity.getAssetListRange(
|
||||||
.getAssetListRange(start: 0, end: album.assetCount);
|
start: 0,
|
||||||
|
end: await album.albumEntity.assetCountAsync,
|
||||||
|
);
|
||||||
assetsFromSelectedAlbums.addAll(assets);
|
assetsFromSelectedAlbums.addAll(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var album in state.excludedBackupAlbums) {
|
for (var album in state.excludedBackupAlbums) {
|
||||||
var assets = await album.albumEntity
|
var assets = await album.albumEntity.getAssetListRange(
|
||||||
.getAssetListRange(start: 0, end: album.assetCount);
|
start: 0,
|
||||||
|
end: await album.albumEntity.assetCountAsync,
|
||||||
|
);
|
||||||
assetsFromExcludedAlbums.addAll(assets);
|
assetsFromExcludedAlbums.addAll(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,11 +361,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||||
state = state.copyWith(backgroundBackup: isEnabled);
|
state = state.copyWith(backgroundBackup: isEnabled);
|
||||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||||
await Future.wait([
|
await _getBackupAlbumsInfo();
|
||||||
_getBackupAlbumsInfo(),
|
await _updateServerInfo();
|
||||||
_updateServerInfo(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await _updateBackupAssetCount();
|
await _updateBackupAssetCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,10 +564,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
albums.lastExcludedBackupTime,
|
albums.lastExcludedBackupTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
backupProgress: previous,
|
backupProgress: previous,
|
||||||
selectedBackupAlbums: selectedAlbums,
|
selectedBackupAlbums: selectedAlbums,
|
||||||
excludedBackupAlbums: excludedAlbums,
|
excludedBackupAlbums: excludedAlbums,
|
||||||
|
backupRequireWifi: backgroundBox.get(backupRequireWifi),
|
||||||
|
backupRequireCharging: backgroundBox.get(backupRequireCharging),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _resumeBackup();
|
return _resumeBackup();
|
||||||
@@ -590,6 +608,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
||||||
|
await Hive.box(backgroundBackupInfoBox).close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||||
|
}
|
||||||
_backgroundService.releaseLock();
|
_backgroundService.releaseLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ class BackupService {
|
|||||||
for (int i = 0; i < albums.length; i++) {
|
for (int i = 0; i < albums.length; i++) {
|
||||||
final AssetPathEntity? a = albums[i];
|
final AssetPathEntity? a = albums[i];
|
||||||
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
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;
|
lastBackup[i] = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2.0),
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
child: Text(
|
child: FutureBuilder(
|
||||||
albumInfo.assetCount.toString() +
|
builder: ((context, snapshot) {
|
||||||
(albumInfo.isAll
|
if (snapshot.hasData) {
|
||||||
? " (${'backup_all'.tr()})"
|
return Text(
|
||||||
: ""),
|
snapshot.data.toString() +
|
||||||
style: TextStyle(
|
(albumInfo.isAll
|
||||||
fontSize: 12,
|
? " (${'backup_all'.tr()})"
|
||||||
color: Colors.grey[600],
|
: ""),
|
||||||
),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text("0");
|
||||||
|
}),
|
||||||
|
future: albumInfo.assetCount,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class BackupControllerPage extends HookConsumerWidget {
|
class BackupControllerPage extends HookConsumerWidget {
|
||||||
const BackupControllerPage({Key? key}) : super(key: key);
|
const BackupControllerPage({Key? key}) : super(key: key);
|
||||||
@@ -156,6 +157,46 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showBatteryOptimizationInfoToUser() {
|
||||||
|
final buttonTextColor = Theme.of(context).primaryColor;
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text(
|
||||||
|
'backup_controller_page_background_battery_info_title',
|
||||||
|
).tr(),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: const Text(
|
||||||
|
'backup_controller_page_background_battery_info_message',
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => launchUrl(
|
||||||
|
Uri.parse('https://dontkillmyapp.com'),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
"backup_controller_page_background_battery_info_link",
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
child: const Text(
|
||||||
|
'backup_controller_page_background_battery_info_ok',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
).tr(),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ListTile _buildBackgroundBackupController() {
|
ListTile _buildBackgroundBackupController() {
|
||||||
final bool isBackgroundEnabled = backupState.backgroundBackup;
|
final bool isBackgroundEnabled = backupState.backgroundBackup;
|
||||||
final bool isWifiRequired = backupState.backupRequireWifi;
|
final bool isWifiRequired = backupState.backupRequireWifi;
|
||||||
@@ -197,6 +238,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
.configureBackgroundBackup(
|
.configureBackgroundBackup(
|
||||||
requireWifi: isChecked,
|
requireWifi: isChecked,
|
||||||
onError: _showErrorToUser,
|
onError: _showErrorToUser,
|
||||||
|
onBatteryInfo: _showBatteryOptimizationInfoToUser,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -217,6 +259,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
.configureBackgroundBackup(
|
.configureBackgroundBackup(
|
||||||
requireCharging: isChecked,
|
requireCharging: isChecked,
|
||||||
onError: _showErrorToUser,
|
onError: _showErrorToUser,
|
||||||
|
onBatteryInfo: _showBatteryOptimizationInfoToUser,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -225,6 +268,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
ref.read(backupProvider.notifier).configureBackgroundBackup(
|
ref.read(backupProvider.notifier).configureBackgroundBackup(
|
||||||
enabled: !isBackgroundEnabled,
|
enabled: !isBackgroundEnabled,
|
||||||
onError: _showErrorToUser,
|
onError: _showErrorToUser,
|
||||||
|
onBatteryInfo: _showBatteryOptimizationInfoToUser,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
isBackgroundEnabled
|
isBackgroundEnabled
|
||||||
@@ -464,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
backupState.currentUploadAsset.createdAt
|
backupState.currentUploadAsset.createdAt
|
||||||
.toString(),
|
.toString(),
|
||||||
),
|
).toLocal(),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -592,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
? ElevatedButton(
|
? ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
primary: Colors.red[300],
|
foregroundColor: Colors.grey[50],
|
||||||
onPrimary: Colors.grey[50],
|
backgroundColor: Colors.red[300],
|
||||||
// padding: const EdgeInsets.all(14),
|
// padding: const EdgeInsets.all(14),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
|||||||
DateFormat.yMMMMd('en_US').format(
|
DateFormat.yMMMMd('en_US').format(
|
||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
errorAsset.createdAt.toString(),
|
errorAsset.createdAt.toString(),
|
||||||
),
|
).toLocal(),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
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
|
var formatDateTemplate = currentYear == groupYear
|
||||||
? "daily_title_text_date".tr()
|
? "daily_title_text_date".tr()
|
||||||
: "daily_title_text_date_year".tr();
|
: "daily_title_text_date_year".tr();
|
||||||
var dateText =
|
var dateText = DateFormat(formatDateTemplate)
|
||||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
.format(DateTime.parse(isoDate).toLocal());
|
||||||
var isMultiSelectEnable =
|
var isMultiSelectEnable =
|
||||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||||
|
|||||||
@@ -33,34 +33,10 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
var assetType = assetGroup[index].type;
|
var assetType = assetGroup[index].type;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: Stack(
|
child: ThumbnailImage(
|
||||||
children: [
|
asset: assetGroup[index],
|
||||||
ThumbnailImage(
|
assetList: sortedAssetGroup,
|
||||||
asset: assetGroup[index],
|
showStorageIndicator: showStorageIndicator,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final BackUpState backupState = ref.watch(backupProvider);
|
final BackUpState backupState = ref.watch(backupProvider);
|
||||||
bool isEnableAutoBackup =
|
bool isEnableAutoBackup = backupState.backgroundBackup ||
|
||||||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||||
|
|
||||||
@@ -42,9 +42,10 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
top: 5,
|
top: 5,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
splashRadius: 25,
|
splashRadius: 25,
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.face_outlined,
|
Icons.face_outlined,
|
||||||
size: 30,
|
size: 30,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Scaffold.of(context).openDrawer();
|
Scaffold.of(context).openDrawer();
|
||||||
@@ -109,7 +110,10 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
splashRadius: 25,
|
splashRadius: 25,
|
||||||
iconSize: 30,
|
iconSize: 30,
|
||||||
icon: isEnableAutoBackup
|
icon: isEnableAutoBackup
|
||||||
? const Icon(Icons.backup_rounded)
|
? Icon(
|
||||||
|
Icons.backup_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
)
|
||||||
: Badge(
|
: Badge(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
@@ -120,7 +124,10 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
size: 8,
|
size: 8,
|
||||||
color: Colors.indigo,
|
color: Colors.indigo,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.backup_rounded),
|
child: Icon(
|
||||||
|
Icons.backup_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
var onPop = await AutoRouter.of(context)
|
var onPop = await AutoRouter.of(context)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||||
.format(DateTime.parse(isoDate));
|
.format(DateTime.parse(isoDate).toLocal());
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
@@ -16,18 +15,18 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
final AssetResponseDto asset;
|
final AssetResponseDto asset;
|
||||||
final List<AssetResponseDto> assetList;
|
final List<AssetResponseDto> assetList;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
|
final bool useGrayBoxPlaceholder;
|
||||||
|
|
||||||
const ThumbnailImage(
|
const ThumbnailImage({
|
||||||
{Key? key,
|
Key? key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.assetList,
|
required this.assetList,
|
||||||
this.showStorageIndicator = true})
|
this.showStorageIndicator = true,
|
||||||
: super(key: key);
|
this.useGrayBoxPlaceholder = false,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final cacheKey = useState(1);
|
|
||||||
|
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||||
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
||||||
@@ -35,7 +34,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||||
|
|
||||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
Widget buildSelectionIcon(AssetResponseDto asset) {
|
||||||
if (selectedAsset.contains(asset)) {
|
if (selectedAsset.contains(asset)) {
|
||||||
return Icon(
|
return Icon(
|
||||||
Icons.check_circle,
|
Icons.check_circle,
|
||||||
@@ -51,7 +50,6 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
debugPrint("View ${asset.id}");
|
|
||||||
if (isMultiSelectEnable &&
|
if (isMultiSelectEnable &&
|
||||||
selectedAsset.contains(asset) &&
|
selectedAsset.contains(asset) &&
|
||||||
selectedAsset.length == 1) {
|
selectedAsset.length == 1) {
|
||||||
@@ -94,25 +92,35 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
: const Border(),
|
: const Border(),
|
||||||
),
|
),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
cacheKey: 'thumbnail-image-${asset.id}',
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 300,
|
height: 300,
|
||||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
|
memCacheHeight: 200,
|
||||||
|
maxWidthDiskCache: 200,
|
||||||
|
maxHeightDiskCache: 200,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
imageUrl: thumbnailRequestUrl,
|
imageUrl: thumbnailRequestUrl,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||||
},
|
},
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
progressIndicatorBuilder: (context, url, downloadProgress) {
|
||||||
Transform.scale(
|
if (useGrayBoxPlaceholder) {
|
||||||
scale: 0.2,
|
return const DecoratedBox(
|
||||||
child: CircularProgressIndicator(
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
value: downloadProgress.progress,
|
);
|
||||||
),
|
}
|
||||||
),
|
return Transform.scale(
|
||||||
|
scale: 0.2,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: downloadProgress.progress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
debugPrint("Error getting thumbnail $url = $error");
|
debugPrint("Error getting thumbnail $url = $error");
|
||||||
|
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
|
||||||
|
|
||||||
return Icon(
|
return Icon(
|
||||||
Icons.image_not_supported_outlined,
|
Icons.image_not_supported_outlined,
|
||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
@@ -125,20 +133,41 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(3.0),
|
padding: const EdgeInsets.all(3.0),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
child: _buildSelectionIcon(asset),
|
child: buildSelectionIcon(asset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showStorageIndicator) Positioned(
|
if (showStorageIndicator)
|
||||||
right: 10,
|
Positioned(
|
||||||
bottom: 5,
|
right: 10,
|
||||||
child: Icon(
|
bottom: 5,
|
||||||
(deviceId != asset.deviceId)
|
child: Icon(
|
||||||
? Icons.cloud_done_outlined
|
(deviceId != asset.deviceId)
|
||||||
: Icons.photo_library_rounded,
|
? Icons.cloud_done_outlined
|
||||||
color: Colors.white,
|
: Icons.photo_library_rounded,
|
||||||
size: 18,
|
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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/control_bottom_app_bar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.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/disable_multi_select_button.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.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/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/immich_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||||
@@ -25,6 +27,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
var renderList = ref.watch(renderListProvider);
|
||||||
|
|
||||||
ScrollController scrollController = useScrollController();
|
ScrollController scrollController = useScrollController();
|
||||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||||
List<Widget> imageGridGroup = [];
|
List<Widget> imageGridGroup = [];
|
||||||
@@ -65,37 +69,45 @@ class HomePage extends HookConsumerWidget {
|
|||||||
int? lastMonth;
|
int? lastMonth;
|
||||||
|
|
||||||
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
||||||
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
try {
|
||||||
int currentMonth = parseDateGroup.month;
|
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
||||||
|
int currentMonth = parseDateGroup.month;
|
||||||
|
|
||||||
if (lastMonth != null) {
|
if (lastMonth != null) {
|
||||||
if (currentMonth - lastMonth! != 0) {
|
if (currentMonth - lastMonth! != 0) {
|
||||||
imageGridGroup.add(
|
imageGridGroup.add(
|
||||||
MonthlyTitleText(
|
MonthlyTitleText(
|
||||||
isoDate: dateGroup,
|
isoDate: dateGroup,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageGridGroup.add(
|
||||||
|
DailyTitleText(
|
||||||
|
key: Key('${dateGroup.toString()}title'),
|
||||||
|
isoDate: dateGroup,
|
||||||
|
assetGroup: immichAssetList,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
imageGridGroup.add(
|
||||||
|
ImageGrid(
|
||||||
|
assetGroup: immichAssetList,
|
||||||
|
sortedAssetGroup: sortedAssetList,
|
||||||
|
tilesPerRow:
|
||||||
|
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
|
showStorageIndicator: appSettingService
|
||||||
|
.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
lastMonth = currentMonth;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
"[ERROR] Cannot parse $dateGroup - Wrong create date format : ${immichAssetList.map((asset) => asset.createdAt).toList()}",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
imageGridGroup.add(
|
|
||||||
DailyTitleText(
|
|
||||||
key: Key('${dateGroup.toString()}title'),
|
|
||||||
isoDate: dateGroup,
|
|
||||||
assetGroup: immichAssetList,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
imageGridGroup.add(
|
|
||||||
ImageGrid(
|
|
||||||
assetGroup: immichAssetList,
|
|
||||||
sortedAssetGroup: sortedAssetList,
|
|
||||||
tilesPerRow: appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
|
||||||
showStorageIndicator: appSettingService.getSetting(AppSettingsEnum.storageIndicator),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
lastMonth = currentMonth;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,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(
|
return SafeArea(
|
||||||
bottom: !isMultiSelectEnable,
|
bottom: !isMultiSelectEnable,
|
||||||
top: !isMultiSelectEnable,
|
top: !isMultiSelectEnable,
|
||||||
@@ -124,17 +161,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
||||||
child: DraggableScrollbar.semicircle(
|
child: _buildAssetGrid(),
|
||||||
backgroundColor: Theme.of(context).hintColor,
|
|
||||||
controller: scrollController,
|
|
||||||
heightScrollThumb: 48.0,
|
|
||||||
child: CustomScrollView(
|
|
||||||
controller: scrollController,
|
|
||||||
slivers: [
|
|
||||||
...imageGridGroup,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (isMultiSelectEnable) ...[
|
if (isMultiSelectEnable) ...[
|
||||||
_buildSelectedItemCountIndicator(),
|
_buildSelectedItemCountIndicator(),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
|
|||||||
(a, b) => b.compareTo(a),
|
(a, b) => b.compareTo(a),
|
||||||
);
|
);
|
||||||
return assets.groupListsBy(
|
return assets.groupListsBy(
|
||||||
(element) =>
|
(element) => DateFormat('y-MM-dd')
|
||||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
_buildThings() {
|
_buildThings() {
|
||||||
return curatedObjects.when(
|
return curatedObjects.when(
|
||||||
loading: () => const SizedBox(
|
loading: () => SizedBox(
|
||||||
height: 200,
|
height: imageSize,
|
||||||
child: Center(child: ImmichLoadingIndicator()),
|
child: const Center(child: ImmichLoadingIndicator()),
|
||||||
),
|
),
|
||||||
error: (err, stack) => Text('Error: $err'),
|
error: (err, stack) => Text('Error: $err'),
|
||||||
data: (objects) {
|
data: (objects) {
|
||||||
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
// height: imageSize,
|
height: imageSize,
|
||||||
width: imageSize,
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.only(left: 16),
|
padding: const EdgeInsets.only(left: 16),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ enum AppSettingsEnum<T> {
|
|||||||
tilesPerRow<int>("tilesPerRow", 4),
|
tilesPerRow<int>("tilesPerRow", 4),
|
||||||
uploadErrorNotificationGracePeriod<int>(
|
uploadErrorNotificationGracePeriod<int>(
|
||||||
"uploadErrorNotificationGracePeriod", 2),
|
"uploadErrorNotificationGracePeriod", 2),
|
||||||
storageIndicator<bool>("storageIndicator", true);
|
storageIndicator<bool>("storageIndicator", true),
|
||||||
|
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||||
|
imageCacheSize<int>("imageCacheSize", 350),
|
||||||
|
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
|
||||||
|
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
|
||||||
|
|
||||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
|
||||||
|
class CacheSettings extends HookConsumerWidget {
|
||||||
|
const CacheSettings({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||||
|
final clearCacheState = useState(false);
|
||||||
|
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
await cacheService.emptyAllCaches();
|
||||||
|
clearCacheState.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget cacheStatisticsRow(String name, CacheType type) {
|
||||||
|
final cacheSize = useState(0);
|
||||||
|
final cacheAssets = useState(0);
|
||||||
|
|
||||||
|
if (!clearCacheState.value) {
|
||||||
|
final repo = cacheService.getCacheRepo(type);
|
||||||
|
|
||||||
|
repo.open().then((_) {
|
||||||
|
cacheSize.value = repo.getCacheSize();
|
||||||
|
cacheAssets.value = repo.getNumberOfCachedObjects();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cacheSize.value = 0;
|
||||||
|
cacheAssets.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(left: 20, bottom: 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"cache_settings_statistics_assets",
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
).tr(
|
||||||
|
args: ["${cacheAssets.value}", formatBytes(cacheSize.value)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExpansionTile(
|
||||||
|
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
textColor: Theme.of(context).primaryColor,
|
||||||
|
title: const Text(
|
||||||
|
'cache_settings_title',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
subtitle: const Text(
|
||||||
|
'cache_settings_subtitle',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
children: [
|
||||||
|
const CacheSettingsSliderPref(
|
||||||
|
setting: AppSettingsEnum.thumbnailCacheSize,
|
||||||
|
translationKey: "cache_settings_thumbnail_size",
|
||||||
|
min: 1000,
|
||||||
|
max: 20000,
|
||||||
|
divisions: 19,
|
||||||
|
),
|
||||||
|
const CacheSettingsSliderPref(
|
||||||
|
setting: AppSettingsEnum.imageCacheSize,
|
||||||
|
translationKey: "cache_settings_image_cache_size",
|
||||||
|
min: 0,
|
||||||
|
max: 1000,
|
||||||
|
divisions: 20,
|
||||||
|
),
|
||||||
|
const CacheSettingsSliderPref(
|
||||||
|
setting: AppSettingsEnum.albumThumbnailCacheSize,
|
||||||
|
translationKey: "cache_settings_album_thumbnails",
|
||||||
|
min: 0,
|
||||||
|
max: 1000,
|
||||||
|
divisions: 20,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
"cache_settings_statistics_title",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
cacheStatisticsRow(
|
||||||
|
"cache_settings_statistics_thumbnail".tr(),
|
||||||
|
CacheType.thumbnail,
|
||||||
|
),
|
||||||
|
cacheStatisticsRow(
|
||||||
|
"cache_settings_statistics_album".tr(),
|
||||||
|
CacheType.albumThumbnail,
|
||||||
|
),
|
||||||
|
cacheStatisticsRow(
|
||||||
|
"cache_settings_statistics_shared".tr(),
|
||||||
|
CacheType.sharedAlbumThumbnail,
|
||||||
|
),
|
||||||
|
cacheStatisticsRow(
|
||||||
|
"cache_settings_statistics_full".tr(),
|
||||||
|
CacheType.imageViewerFull,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
"cache_settings_clear_cache_button_title",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: clearCache,
|
||||||
|
child: const Text(
|
||||||
|
"cache_settings_clear_cache_button",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
|
|
||||||
|
class CacheSettingsSliderPref extends HookConsumerWidget {
|
||||||
|
final AppSettingsEnum<int> setting;
|
||||||
|
final String translationKey;
|
||||||
|
final int min;
|
||||||
|
final int max;
|
||||||
|
final int divisions;
|
||||||
|
|
||||||
|
const CacheSettingsSliderPref({
|
||||||
|
Key? key,
|
||||||
|
required this.setting,
|
||||||
|
required this.translationKey,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
required this.divisions,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
|
final itemsValue = useState(appSettingService.getSetting<int>(setting));
|
||||||
|
|
||||||
|
void sliderChanged(double value) {
|
||||||
|
itemsValue.value = value.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void sliderChangedEnd(double value) {
|
||||||
|
appSettingService.setSetting(setting, value.toInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
translationKey,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(args: ["${itemsValue.value.toInt()}"]),
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
onChangeEnd: sliderChangedEnd,
|
||||||
|
onChanged: sliderChanged,
|
||||||
|
value: itemsValue.value.toDouble(),
|
||||||
|
min: min.toDouble(),
|
||||||
|
max: max.toDouble(),
|
||||||
|
divisions: divisions,
|
||||||
|
label: "${itemsValue.value.toInt()}",
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_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/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/notification_setting/notification_setting.dart';
|
||||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||||
@@ -42,6 +43,7 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
const ThemeSetting(),
|
const ThemeSetting(),
|
||||||
const AssetListSettings(),
|
const AssetListSettings(),
|
||||||
if (Platform.isAndroid) const NotificationSetting(),
|
if (Platform.isAndroid) const NotificationSetting(),
|
||||||
|
const ExperimentalSettings(),
|
||||||
],
|
],
|
||||||
).toList(),
|
).toList(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
authToken: args.authToken,
|
authToken: args.authToken,
|
||||||
isZoomedFunction: args.isZoomedFunction,
|
isZoomedFunction: args.isZoomedFunction,
|
||||||
isZoomedListener: args.isZoomedListener,
|
isZoomedListener: args.isZoomedListener,
|
||||||
onLoadingCompleted: args.onLoadingCompleted,
|
|
||||||
onLoadingStart: args.onLoadingStart,
|
|
||||||
threeStageLoading: args.threeStageLoading));
|
threeStageLoading: args.threeStageLoading));
|
||||||
},
|
},
|
||||||
VideoViewerRoute.name: (routeData) {
|
VideoViewerRoute.name: (routeData) {
|
||||||
@@ -297,8 +295,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
|||||||
required String authToken,
|
required String authToken,
|
||||||
required void Function() isZoomedFunction,
|
required void Function() isZoomedFunction,
|
||||||
required ValueNotifier<bool> isZoomedListener,
|
required ValueNotifier<bool> isZoomedListener,
|
||||||
required void Function() onLoadingCompleted,
|
|
||||||
required void Function() onLoadingStart,
|
|
||||||
required bool threeStageLoading})
|
required bool threeStageLoading})
|
||||||
: super(ImageViewerRoute.name,
|
: super(ImageViewerRoute.name,
|
||||||
path: '/image-viewer-page',
|
path: '/image-viewer-page',
|
||||||
@@ -309,8 +305,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
|||||||
authToken: authToken,
|
authToken: authToken,
|
||||||
isZoomedFunction: isZoomedFunction,
|
isZoomedFunction: isZoomedFunction,
|
||||||
isZoomedListener: isZoomedListener,
|
isZoomedListener: isZoomedListener,
|
||||||
onLoadingCompleted: onLoadingCompleted,
|
|
||||||
onLoadingStart: onLoadingStart,
|
|
||||||
threeStageLoading: threeStageLoading));
|
threeStageLoading: threeStageLoading));
|
||||||
|
|
||||||
static const String name = 'ImageViewerRoute';
|
static const String name = 'ImageViewerRoute';
|
||||||
@@ -324,8 +318,6 @@ class ImageViewerRouteArgs {
|
|||||||
required this.authToken,
|
required this.authToken,
|
||||||
required this.isZoomedFunction,
|
required this.isZoomedFunction,
|
||||||
required this.isZoomedListener,
|
required this.isZoomedListener,
|
||||||
required this.onLoadingCompleted,
|
|
||||||
required this.onLoadingStart,
|
|
||||||
required this.threeStageLoading});
|
required this.threeStageLoading});
|
||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
@@ -340,15 +332,11 @@ class ImageViewerRouteArgs {
|
|||||||
|
|
||||||
final ValueNotifier<bool> isZoomedListener;
|
final ValueNotifier<bool> isZoomedListener;
|
||||||
|
|
||||||
final void Function() onLoadingCompleted;
|
|
||||||
|
|
||||||
final void Function() onLoadingStart;
|
|
||||||
|
|
||||||
final bool threeStageLoading;
|
final bool threeStageLoading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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),
|
(a, b) => b.compareTo(a),
|
||||||
);
|
);
|
||||||
return assets.groupListsBy(
|
return assets.groupListsBy(
|
||||||
(element) =>
|
(element) => DateFormat('y-MM-dd')
|
||||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return assets.groupListsBy(
|
return assets.groupListsBy(
|
||||||
(element) =>
|
(element) => DateFormat('MMMM, y')
|
||||||
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)),
|
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,81 @@
|
|||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/utils/immich_cache_info_repository.dart';
|
||||||
|
|
||||||
enum CacheType {
|
enum CacheType {
|
||||||
|
// Shared cache for asset thumbnails in various modules
|
||||||
|
thumbnail,
|
||||||
|
imageViewerPreview,
|
||||||
|
imageViewerFull,
|
||||||
albumThumbnail,
|
albumThumbnail,
|
||||||
sharedAlbumThumbnail;
|
sharedAlbumThumbnail;
|
||||||
}
|
}
|
||||||
|
|
||||||
final cacheServiceProvider = Provider((_) => CacheService());
|
final cacheServiceProvider = Provider(
|
||||||
|
(ref) => CacheService(ref.watch(appSettingsServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
class CacheService {
|
class CacheService {
|
||||||
|
final AppSettingsService _settingsService;
|
||||||
|
final _cacheRepositoryInstances = <CacheType, ImmichCacheRepository>{};
|
||||||
|
|
||||||
|
CacheService(this._settingsService);
|
||||||
|
|
||||||
BaseCacheManager getCache(CacheType type) {
|
BaseCacheManager getCache(CacheType type) {
|
||||||
return _getDefaultCache(type.name);
|
return _getDefaultCache(
|
||||||
|
type.name,
|
||||||
|
_getCacheSize(type) + 1,
|
||||||
|
getCacheRepo(type),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseCacheManager _getDefaultCache(String cacheName) {
|
ImmichCacheRepository getCacheRepo(CacheType type) {
|
||||||
return CacheManager(Config(cacheName));
|
if (!_cacheRepositoryInstances.containsKey(type)) {
|
||||||
|
final repo = ImmichCacheInfoRepository(
|
||||||
|
"cache_${type.name}",
|
||||||
|
"cacheKeys_${type.name}",
|
||||||
|
);
|
||||||
|
_cacheRepositoryInstances[type] = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cacheRepositoryInstances[type]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
Future<void> emptyAllCaches() async {
|
||||||
|
for (var type in CacheType.values) {
|
||||||
|
await getCache(type).emptyCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _getCacheSize(CacheType type) {
|
||||||
|
switch (type) {
|
||||||
|
case CacheType.thumbnail:
|
||||||
|
return _settingsService.getSetting(AppSettingsEnum.thumbnailCacheSize);
|
||||||
|
case CacheType.imageViewerPreview:
|
||||||
|
case CacheType.imageViewerFull:
|
||||||
|
return _settingsService.getSetting(AppSettingsEnum.imageCacheSize);
|
||||||
|
case CacheType.sharedAlbumThumbnail:
|
||||||
|
case CacheType.albumThumbnail:
|
||||||
|
return _settingsService
|
||||||
|
.getSetting(AppSettingsEnum.albumThumbnailCacheSize);
|
||||||
|
default:
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseCacheManager _getDefaultCache(
|
||||||
|
String cacheName,
|
||||||
|
int size,
|
||||||
|
CacheInfoRepository repo,
|
||||||
|
) {
|
||||||
|
return CacheManager(
|
||||||
|
Config(
|
||||||
|
cacheName,
|
||||||
|
maxNrOfCacheObjects: size,
|
||||||
|
repo: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class ImmichToast {
|
|||||||
ToastType toastType = ToastType.info,
|
ToastType toastType = ToastType.info,
|
||||||
ToastGravity gravity = ToastGravity.TOP,
|
ToastGravity gravity = ToastGravity.TOP,
|
||||||
}) {
|
}) {
|
||||||
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
final fToast = FToast();
|
final fToast = FToast();
|
||||||
fToast.init(context);
|
fToast.init(context);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ class ImmichToast {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(5.0),
|
borderRadius: BorderRadius.circular(5.0),
|
||||||
color: Colors.grey[50],
|
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.black12,
|
color: Colors.black12,
|
||||||
width: 1,
|
width: 1,
|
||||||
|
|||||||
15
mobile/lib/utils/bytes_units.dart
Normal file
15
mobile/lib/utils/bytes_units.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
String formatBytes(int bytes) {
|
||||||
|
if (bytes < 1000) {
|
||||||
|
return "$bytes B";
|
||||||
|
} else if (bytes < 1000000) {
|
||||||
|
final kb = (bytes / 1000).toStringAsFixed(1);
|
||||||
|
return "$kb kB";
|
||||||
|
} else if (bytes < 1000000000) {
|
||||||
|
final mb = (bytes / 1000000).toStringAsFixed(1);
|
||||||
|
return "$mb MB";
|
||||||
|
} else {
|
||||||
|
final gb = (bytes / 1000000000).toStringAsFixed(1);
|
||||||
|
return "$gb GB";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ class FileHelper {
|
|||||||
case 'png':
|
case 'png':
|
||||||
return {"type": "image", "subType": "png"};
|
return {"type": "image", "subType": "png"};
|
||||||
|
|
||||||
|
case 'tif':
|
||||||
|
return {"type": "image", "subType": "tiff"};
|
||||||
|
|
||||||
case 'mov':
|
case 'mov':
|
||||||
return {"type": "video", "subType": "quicktime"};
|
return {"type": "video", "subType": "quicktime"};
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ class FileHelper {
|
|||||||
case 'webp':
|
case 'webp':
|
||||||
return {"type": "image", "subType": "webp"};
|
return {"type": "image", "subType": "webp"};
|
||||||
|
|
||||||
|
case '3gp':
|
||||||
|
return {"type": "video", "subType": "3gpp"};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {"type": "unsupport", "subType": "unsupport"};
|
return {"type": "unsupport", "subType": "unsupport"};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user