Compare commits

..

46 Commits

Author SHA1 Message Date
Alex
25338ce02f Fixed issue with filename is blank on iOS causing update to fail 2022-08-18 16:27:08 -05:00
Alex Tran
4805d86a7c pump version 2022-08-18 15:01:45 -05:00
Fynn Petersen-Frey
33b1410d82 upload new photos in background with a service (#382)
* properly done background backup service

* new concurrency/locking management with heartbeat

fix communication erros with Kotlin plugin on start/stop service methods

better error handling for BackgroundService public methods

Add default notification message when service is running

* configurable WiFi & charging requirement for service

* use translations in background service
2022-08-18 09:41:59 -05:00
r3nor
f35ebec7c6 Readme redistribution and reorganisation (#485)
* redistribute and organize readme

* Fix typos

* Change developers to development

* Remove mobile app emoji

* Multiple changes

- Removed the .env example inbody to link to the .env.example file; this way it won't be needed to change the readme if the .env file changes and the readme gets shorter.
- Redistributed the steps:
  - Inline instead of tables
  - bullets in steps
- Fix wording
2022-08-18 08:31:00 -05:00
Thanh Pham
3aa6ee0320 feat: remove webp on asset deleted as well (#489)
* fix(server): remove webp file on asset deleted

* chore(server): job not fail when file not found
2022-08-18 08:25:03 -05:00
Thanh Pham
cdb0aa00d8 feat(server, microservices): add bull prefix (#490) 2022-08-18 08:24:07 -05:00
Alex
9de7b8d3a7 Create github-repo-stats.yml 2022-08-16 22:56:20 -05:00
Max
4a28a46612 fix spelling mistakes (#479) 2022-08-16 13:50:45 -05:00
Malte Kiefer
16561d15ff Added German translation for the settings view (#478)
* added translation for the settings view

* added translation for the settings view
2022-08-16 11:00:48 -05:00
Alex
9642ad2820 Fixed Websocket not getting correct data on mobile 2022-08-15 23:43:12 -05:00
Alex
e2169a26c2 Remove padding bottom on photos page mobile 2022-08-15 23:11:54 -05:00
Alex
f697922f32 Remove hardcode UIsystemLight in info.plist (#475) 2022-08-15 23:10:51 -05:00
Alex Tran
1390d01763 Up version 2022-08-15 19:13:51 -05:00
Alex
86f780871c Fixed different lettercases in email create different user (#470)
* Fixed different lettercases in email create different user

* Fixed test
2022-08-15 19:11:08 -05:00
Alex
c1b22125fd Add mobile dark mode and user setting (#468)
* styling light and dark theme

* Icon topbar

* Fixed app bar title dark theme

* Fixed issue with getting thumbnail for things

* Refactor sharing page

* Refactor scroll thumb

* Refactor chip in auto  backup indiation button

* Refactor sharing page

* Added theme toggle

* Up version for testflight build

* Refactor backup controller page

* Refactor album selection page

* refactor album pages

* Refactor gradient color profile header

* Added theme switcher

* Register app theme correctly

* Added locale to the app

* Added translation key

* Styling for bottomsheet colors

* up server version

* Fixed font size

* Fixed overlapsed sliverappbar on photos screen
2022-08-15 18:53:30 -05:00
Alex
30f069a5db Add settings screen on mobile (#463)
* Refactor profile drawer to sub component

* Added setting page, routing with some options

* Added setting service

* Implement three stage settings

* get app setting for three stage loading
2022-08-13 15:51:09 -05:00
bo0tzz
2bf6cd9241 Fix redirect to login page after password change (#461)
* Fix redirect to login page after password change

Copied from the similar fix in #414

* Fix typo in change-password form

* Remove misplaced text from user management page
2022-08-13 09:54:29 -05:00
Alex Tran
87d2a954a3 Fixed error handling with catch block 2022-08-12 22:29:24 -05:00
Alex
a388c5a642 Fixed webp upload on web (#460) 2022-08-12 21:52:30 -05:00
Alex Tran
4b34f017ca cosmetic change 2022-08-12 21:19:54 -05:00
Alex Tran
5c1d1dd5a1 Added version note for f-droid 2022-08-12 20:10:00 -05:00
Alex Tran
1580d27c23 Up version 2022-08-12 20:06:45 -05:00
Alex
4b9187928c Edit user on the web (#458)
* Added dispatch event for edit user

* Fixed import location

* solve merge conflict

* Fixed issue not admin user can access admin page

* Implemented edit user and password reset
2022-08-12 14:25:19 -05:00
Alex Tran
5b7236f6ad Temporary remove bug tests 2022-08-11 23:17:09 -05:00
Alex Tran
6fb439b580 Fixed merge conflict 2022-08-11 13:46:42 -05:00
Alex Tran
a8334b5c27 Fixed test again 2022-08-11 13:46:11 -05:00
Alex Tran
e1cac93945 Fixed test 2022-08-11 09:29:53 -05:00
R0GGER
081f9f5bce typo (#456) 2022-08-11 08:33:44 -05:00
Alex Tran
25ccc5660d Merge branch 'main' of github.com:immich-app/immich 2022-08-11 08:27:48 -05:00
Alex Tran
b6d3e578f2 Added test and github action for unit tests 2022-08-11 08:27:44 -05:00
Matthias Rupp
52377c2dcf Fix sharing on iPad (#453) 2022-08-11 08:13:33 -05:00
Alex
5c78f707fe Modify Album API endpoint to return a count attribute instead of a full assets array (#454)
* Change API to return assets count and change web behavior accordingly

* Refactor assets.length

* Explicitly declare type of assetCount so Dart SDK understand it

* Finished refactoring on mobile
2022-08-10 22:48:25 -05:00
Alex Tran
bd5ed1b684 Merge branch 'main' of github.com:immich-app/immich 2022-08-09 19:12:32 -05:00
Alex Tran
e89339b813 Up server version 2022-08-09 19:12:21 -05:00
Alex
0b69feda40 Fixed checkbox render performance (#448) 2022-08-09 19:10:55 -05:00
Alex
339f7f776f Fixed setting high refresh rate crash ios release build 2022-08-08 23:43:48 -05:00
Alex Tran
7e6ccbad21 Up server version 2022-08-08 22:55:35 -05:00
Alex Tran
aac53e5cdc Up version for release 2022-08-08 22:39:32 -05:00
Alex Tran
cbec75a175 Rewording delete caution message 2022-08-08 22:13:36 -05:00
Alex
bf04d9eb39 Feature - Delete asset on the web (#436)
* Added selection mechanism to photos page

* Added control app bar

* Refactor AlbumAppBar into ControlAppBar

* Added addtional micro interactions when in multi selection mode

* Implemented delete selected asset and rerender
2022-08-08 22:06:11 -05:00
Malte Kiefer
3058c894b1 updated German translation (#444) 2022-08-08 21:21:02 -05:00
Matthias Rupp
e57e279fe1 Share assets from mobile to other apps (#435)
* Share unique assets

* Style share preparing dialog

* Share assets from multiselect

* Fix i18n

* Use navigator like in delete dialog

* Center bottom-bar buttons
2022-08-08 10:46:12 -05:00
dependabot[bot]
f43c58fc6d Bump docker/build-push-action from 3.1.0 to 3.1.1 (#441)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-08 08:22:14 -05:00
Matthias Rupp
dea304ac39 Fix/album title (#440)
* Fix album title overflow

* i18n

* More i18n
2022-08-08 08:11:56 -05:00
Matthias Rupp
b46e834220 Mobile performance improvements (#417)
* First performance tweaks (caching and rendering improvemetns)

* Revert asset response caching

* 3-step image loading in asset viewer

* Prevent panning and zooming until full-scale version is loaded

* Loading indicator

* Adapt to gallery PR

* Cleanup

* Dart format

* Fix exif sheet

* Disable three stage loading until settings are available
2022-08-07 19:43:09 -05:00
Alex Tran
46f4905259 Up server version 2022-08-07 18:42:21 -05:00
131 changed files with 4183 additions and 1519 deletions

View File

@@ -27,7 +27,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./server
file: ./server/Dockerfile
@@ -55,7 +55,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -82,7 +82,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./web
file: ./web/Dockerfile
@@ -110,7 +110,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./nginx
file: ./nginx/Dockerfile

View File

@@ -30,7 +30,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./server
file: ./server/Dockerfile
@@ -59,7 +59,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -87,7 +87,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./web
file: ./web/Dockerfile
@@ -116,7 +116,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./nginx
file: ./nginx/Dockerfile

View File

@@ -35,7 +35,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-server release
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./server
file: ./server/Dockerfile
@@ -68,7 +68,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./machine-learning
file: ./machine-learning/Dockerfile
@@ -107,7 +107,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-web release
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./web
file: ./web/Dockerfile
@@ -147,7 +147,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-proxy release
uses: docker/build-push-action@v3.1.0
uses: docker/build-push-action@v3.1.1
with:
context: ./nginx
file: ./nginx/Dockerfile

19
.github/workflows/github-repo-stats.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: github-repo-stats
on:
schedule:
# Run this once per day, towards the end of the day for keeping the most
# recent data point most meaningful (hours are interpreted in UTC).
- cron: "0 23 * * *"
workflow_dispatch: # Allow for running this manually.
jobs:
j1:
name: github-repo-stats
runs-on: ubuntu-latest
steps:
- name: run-ghrs
# Use latest release.
uses: jgehrcke/github-repo-stats@RELEASE
with:
ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}

View File

@@ -2,11 +2,12 @@ name: Test
on:
workflow_dispatch:
pull_request:
push: { branches: master }
push:
branches: [main]
jobs:
test-server-e2e:
name: Run test suite
e2e-tests:
name: Run end-to-end test suites
runs-on: ubuntu-latest
@@ -16,3 +17,14 @@ jobs:
- 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
unit-tests:
name: Run unit test suites
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests
run: cd server && npm install && npm run test

View File

@@ -1,6 +1,9 @@
dev:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans

257
README.md
View File

@@ -1,3 +1,9 @@
<h1 align="center"> Immich </h1>
<p align="center"> <b>High performance self-hosted photo and video backup solution.</b> </p>
<p align="center">
<img src="design/feature-panel.png" title="Immich Logo">
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://github.com/alextran1502/immich"><img src="https://img.shields.io/github/stars/alextran1502/immich.svg?style=for-the-badge&logo=github&color=3F51B5&label=Stars&logoColor=000000&labelColor=ececec" alt="Star on Github"></a>
@@ -14,75 +20,66 @@
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
</a>
<br/>
<br/>
<br/>
<br/>
<p align="center">
<img src="design/feature-panel.png" title="Immich Logo">
</p>
<br/>
</p>
# Immich
**High performance self-hosted photo and video backup solution.**
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
Loading ~4000 images/videos
## Screenshots
### Mobile
<p align="left">
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/home-screen.jpeg" width="150" title="Home Screen">
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
<img src="design/shared-albums.png" width="150" title="Shared Albums">
<img src="design/nsc6.png" width="150" title="EXIF Info">
</p>
### Web
<p align="left">
<img src="design/web-home.jpeg" width="49%" title="Home Dashboard">
<img src="design/web-detail.jpeg" width="49%" title="Detail">
</p>
# Note
**!! 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.
## Content
- [Features](#features)
- [Screenshots](#screenshots)
- [Installation](#installation)
- [Mobile App](#-mobile-app)
- [Development](#development)
- [Support](#support)
- [Known Issues](#known-issues)
# Features
> ⚠️ 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 |
| - | - | - |
| Upload and view videos and photos | Yes | Yes
| Auto backup when the app is opened | Yes | N/A
| Selective album(s) for backup | Yes | N/A
| Download photos and videos to local device | Yes | Yes
| Multi-user support | Yes | Yes
| Album | Yes | Yes
| Shared Albums | Yes | Yes
| Quick navigation with draggable scrollbar | Yes | Yes
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
| Metadata view (EXIF, map) | Yes | Yes
| Search by metadata, objects and image tags | Yes | No
| Administrative functions (user management) | N/A | Yes
| - | - | - |
| ☁️ Upload and view videos and photos | Yes | Yes
| 🔄 Auto backup when the app is opened | Yes | N/A
| ☑️ Selective album(s) for backup | Yes | N/A
| ⬇️ Download photos and videos to local device | Yes | Yes
| 👪 Multi-user support | Yes | Yes
| 🖼️ Album | Yes | Yes
| 🤝 Shared Albums | Yes | Yes
| 🚀 Quick navigation with draggable scrollbar | Yes | Yes
| 🗃️ Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
| 🧭 Metadata view (EXIF, map) | Yes | Yes
| 🔎 Search by metadata, objects and image tags | Yes | No
| ⚙️ Administrative functions (user management) | N/A | Yes
# System Requirement
<br/>
**OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
# Screenshots
**RAM**: At least 2GB, preffered 4GB.
### Mobile
| | | | | |
| - | - | - | - | - |
| <img src="design/login-screen.png" width="150" title="Login With Custom URL"> <p align="center"> Login with custom URL </p> | <img src="design/backup-screen.png" width="150" title="Backup Setting Info"> <p align="center"> Backup Settings </p> | <img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info"> <p align="center"> Backup selection </p> | <img src="design/home-screen.jpeg" width="150" title="Home Screen"> <p align="center"> Home Screen </p> | <img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> <p align="center"> Curated search </p> |
| <img src="design/shared-albums.png" width="150" title="Shared Albums"> <p align="center"> Shared albums </p> | <img src="design/nsc6.png" width="150" title="EXIF Info"> <p align="center"> EXIF info </p> | <img src="https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif" width="150" title="Loading ~4000 images/videos"> <p align="center"> Loading ~4000 images/videos </p> |
**Core**: At least 2 cores, preffered 4 cores.
### Web
| Home Dashboard | Image view |
| - | - |
|<img src="design/web-home.jpeg" width="100%" title="Home Dashboard"> | <img src="design/web-detail.jpeg" width="100%" title="Detail">|
# Technology Stack
<br/>
# Project Details
## 💾 System Requirements
- **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
- **RAM**: At least 2GB, preferred 4GB.
- **Core**: At least 2 cores, preferred 4 cores.
## 🔩 Technology Stack
There are several services that compose Immich:
@@ -93,15 +90,18 @@ There are several services that compose Immich:
5. **Nginx** - Load balancing and optimized file uploading.
6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet).
# Installing
## One-step installation - for evaluating only
<br/>
# Installation
## Testing One-step installation (not recommended for production)
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
*Applicable system: Ubuntu, Debian, MacOS*
*This installation method is for evaluating Immich before futher customization to meet the users' needs.*
In the shell, from the directory of your choice, run the following command:
- In the shell, from the directory of your choice, run the following command:
```bash
curl -o- https://raw.githubusercontent.com/immich-app/immich/main/install.sh | bash
@@ -114,116 +114,70 @@ The web application will be available at `http://<machine-ip-address>:2283`, and
The directory which is used to store the backup file is `./immich-app/immich-data`.
## Customize installation - for production usage
<br/>
## Custom installation (Recommended)
### Step 1 - Download necessary files
Create a directory called `immich-app` and cd into it. Then
- Create a directory called `immich-app` and cd into it.
Get `docker-compose.yml`
- Get `docker-compose.yml`
```bash
wget https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml
```
Get `.env`
- Get `.env`
```bash
wget -O .env wget https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
```
### Step 2 - Populate .env file with customed information
### Step 2 - Populate .env file with custom information
* Populate customised database information if necessary.
<a href="https://github.com/immich-app/immich/blob/main/docker/.env.example" target="_blank"><b>See the example <code>.env</code> file</b></a>
* Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`
* [Optional] Populate Mapbox value.
**Example**
```bash
###################################################################################
# Database
###################################################################################
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
###################################################################################
# Upload File Config
###################################################################################
UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here>
###################################################################################
# JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX
####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=
```
* 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
Run `docker-compose up` or `docker compose up` (based on your docker's version)
- Run `docker-compose up` or `docker compose up` (based on your docker's version)
### Step 4 - Register admin user
Navigate to the web at `http://<machine-ip-address>:2283` and follow the prompts to register admin user.
<p align="left">
- Navigate to the web at `http://<machine-ip-address>:2283` and follow the prompts to register admin user.
<p align="center">
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
<p/>
</p>
Additional accounts on the server can be created by the admin account.
<p align="left">
<img src="design/admin-interface.png" width="500" title="Admin User Management">
<p/>
- You can add and manage users from the administration page.
<p align="center">
<img src="design/admin-interface.png" width="500" title="Admin User Management">
</p>
### Step 5 - Access the mobile app
Login the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
<p align="left">
- Login the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
<p align="center">
<img src="design/login-screen.jpeg" width="250" title="Example login screen">
<p/>
</p>
## Mobile app
<br/>
## F-Droid
You can get the app on F-droid by clicking the image below.
# Mobile app
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/app.alextran.immich)
| 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/> |
> *The App version might be lagging behind the latest release due to the review process.*
## Android
#### Get the app on Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
*The App version might be lagging behind the latest release due to the review process.*
<p align="left">
<img src="design/google-play-qr-code.png" width="200" title="Google Play Store">
<p/>
## iOS
#### Get the app on Apple AppStore [here](https://apps.apple.com/us/app/immich/id1613945652):
*The App version might be lagging behind the latest release due to the review process.*
<p align="left">
<img src="design/ios-qr-code.png" width="200" title="Apple App Store">
<p/>
<br/>
# Development
@@ -244,31 +198,28 @@ npm run api:generate # Run from server directory
```
You can find the generated client SDK in the [`web/src/api`](web/src/api) for Typescript SDK and [`mobile/openapi`](mobile/openapi) for Dart SDK.
<br/>
# Support
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**one time**](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or monthly donation from [**Github Sponsor**](https://github.com/sponsors/alextran1502)
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**one time**](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or monthly donation from [**Github Sponsor**](https://github.com/sponsors/alextran1502).
You can also donate using crypto currency with the following addresses:
<p align="left" style="display: flex; place-items: center; gap: 20px" title="Bitcoin(BTC)">
<img src="design/bitcoin.png" width="25" title="Bitcoin">
<code>1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX</code>
</p>
<p align="" style="display: flex; place-items: center; gap: 15px" title="Bitcoin(BTC)"><img src="design/bitcoin.png" width="25" title="Bitcoin"> <b>Bitcoin</b>: <code>1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX</code></p>
<p align="left" style="display: flex; place-items: center; gap: 15px" title="Cardano(ADA)">
<img src="design/cardano.png" width="30" title="Cardano">
<code>
addr1qyy567vqhqrr3p7vpszr5p264gw89sqcwts2z8wqy4yek87cdmy79zazyjp7tmwhkluhk3krvslkzfvg0h43tytp3f5q49nycc
</code>
</p>
<p align="" style="display: flex; place-items: center; gap: 15px" title="Cardano(ADA)"> <img src="design/cardano.png" width="30" title="Cardano"> <b>Cardano</b>: <code>addr1qyy567vqhqrr3p7vpszr5p264gw89sqcwts2z8wqy4yek87cdmy79zazyjp7tmwhkluhk3krvslkzfvg0h43tytp3f5q49nycc</code> </p>
This is also a meaningful way to give me motivation and encouragement to continue working on the app.
Cheers! 🎉
# Known Issue
<br/>
# Known Issues
## TensorFlow Build Issue

View File

@@ -80,5 +80,8 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
implementation "com.google.guava:guava:$guava_version"
}

View File

@@ -1,6 +0,0 @@
package com.example.immich_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}

View File

@@ -0,0 +1,98 @@
package app.alextran.immich
import android.content.Context
import android.net.Uri
import android.content.Intent
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
private var context: Context? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
}
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onDetachedFromEngine()
}
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
when(call.method) {
"initialize" -> { // needs to be called prior to any other method
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
result.success(true)
}
"start" -> {
val args = call.arguments<ArrayList<*>>()!!
val immediate = args.get(0) as Boolean
val keepExisting = args.get(1) as Boolean
val requireUnmeteredNetwork = args.get(2) as Boolean
val requireCharging = args.get(3) as Boolean
val notificationTitle = args.get(4) as String
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
result.success(true)
}
"stop" -> {
BackupWorker.stopWork(ctx)
result.success(true)
}
"isEnabled" -> {
result.success(BackupWorker.isEnabled(ctx))
}
"disableBatteryOptimizations" -> {
if(!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()
}
}
}
private const val TAG = "BackgroundServicePlugin"

View File

@@ -0,0 +1,333 @@
package app.alextran.immich
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.provider.MediaStore
import android.provider.BaseColumns
import android.provider.MediaStore.MediaColumns
import android.provider.MediaStore.Images.Media
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.NetworkType
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager to perform backup in background
*
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met,
* i.e. a new photo/video is created on the device AND battery is not low.
* Optionally, unmetered network (wifi) and charging can be required.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again with the same settings.
* In case the worker is stopped by the system (e.g. constraints like wifi
* are no longer met, or the system needs memory resources for more other
* more important work), the worker is replaced without the constraint on
* changed contents to run again as soon as deemed possible by the system.
*/
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
private val resolvableFuture = ResolvableFuture.create<Result>()
private var engine: FlutterEngine? = null
private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
val ctx = applicationContext
// enqueue itself once again to continue to listen on added photos/videos
enqueueMoreWork(ctx,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a Notification channel if necessary
createChannel()
}
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by 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))
}
engine = FlutterEngine(ctx)
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
return resolvableFuture
}
/**
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
*/
private fun runDart() {
val callbackDispatcherHandle = applicationContext.getSharedPreferences(
SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L)
val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle)
val appBundlePath = flutterLoader.findAppBundlePath()
engine?.let { engine ->
backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel")
backgroundChannel.setMethodCallHandler(this@BackupWorker)
engine.dartExecutor.executeDartCallback(
DartExecutor.DartCallback(
applicationContext.assets,
appBundlePath,
callbackInformation
)
)
}
}
override fun onStopped() {
// called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
backgroundChannel.invokeMethod("systemStop", null)
}
// cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException)
// instead, wait for 5 seconds until forcefully stopping backup work
Handler(Looper.getMainLooper()).postDelayed({
stopEngine(null)
}, 5000)
}
private fun stopEngine(result: Result?) {
if (result != null) {
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),
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
engine?.destroy()
engine = null
}
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) {
"initialized" ->
backgroundChannel.invokeMethod(
"onAssetsChanged",
null,
object : MethodChannel.Result {
override fun notImplemented() {
stopEngine(Result.failure())
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
stopEngine(Result.failure())
}
override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry())
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// there was an error (e.g. server not available)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
}
}
)
"updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String
if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content))
}
}
"showError" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String
showError(title, content)
}
else -> r.notImplemented()
}
}
private fun showError(title: String, content: String) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true)
.build()
val notificationId = SystemClock.uptimeMillis() as Int
notificationManager.notify(notificationId, notification)
}
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.build()
return ForegroundInfo(1, notification)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createChannel() {
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(foreground)
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
notificationManager.createNotificationChannel(error)
}
companion object {
const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
private const val TASK_NAME = "immich/photoListener"
private const val DATA_KEY_UNMETERED = "unmetered"
private const val DATA_KEY_CHARGING = "charging"
private const val DATA_KEY_RETRIES = "retries"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
/**
* Enqueues the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param immediate whether to enqueue(replace) the worker without the content change constraint
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
* @param retries retry count (should be 0 unless an error occured and this is a retry)
*/
fun startWork(context: Context,
immediate: Boolean = false,
keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
}
private fun enqueueMoreWork(context: Context,
immediate: Boolean = false,
keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false,
retries: Int = 0) {
if (!isEnabled(context)) {
return
}
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)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS)
.build()
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
val result = op.getResult().get()
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun stopWork(context: Context) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
}
/**
* Returns `true` if the app is ignoring battery optimizations
*/
fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
val name = ctx.packageName
return pwrm.isIgnoringBatteryOptimizations(name)
}
return true
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
}
private val flutterLoader = FlutterLoader()
}
}
private const val TAG = "BackupWorker"

View File

@@ -1,6 +1,13 @@
package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.getPlugins().add(BackgroundServicePlugin())
}
}

View File

@@ -1,5 +1,8 @@
buildscript {
ext.kotlin_version = '1.6.10'
ext.work_version = '2.7.1'
ext.concurrent_version = '1.1.0'
ext.guava_version = '31.0.1-android'
repositories {
google()
mavenCentral()

View File

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

View File

@@ -15,13 +15,21 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android
### android build
```sh
[bundle exec] fastlane android build
```
Build Android
### android release
```sh
[bundle exec] fastlane android release
```
Update AAB to PlayStore
Build and Release Android
----

View File

@@ -0,0 +1,3 @@
* Improve performance
* Fix album title overflow
* New feature - Share asset from mobile to other apps

View File

@@ -0,0 +1 @@
* Modify Album API endpoint to return count attribute instead of all assets to reduce network consumption and CPU processing.

View File

@@ -0,0 +1,2 @@
* Added setting screen
* Implemented dark mode

View File

@@ -0,0 +1,3 @@
* Feature - [Android] Background backup.
* Fixed - [iOS] Dark mode not auto switch.
* Fixed - WebSocket not getting correct data on mobile.

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000204">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000221">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="11.673502">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.750133">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="37.162935">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.558064">
</testcase>

View File

@@ -47,7 +47,8 @@
"backup_info_card_assets": "Elemente",
"control_bottom_app_bar_delete": "Löschen",
"create_shared_album_page_share": "Teilen",
"create_shared_album_page_share_add_assets": "ELEMENTE HINZUFÜGEN",
"create_shared_album_page_create": "Erstellen",
"create_shared_album_page_share_add_assets": "FOTOS HINZUFÜGEN",
"create_shared_album_page_share_select_photos": "Fotos auswählen",
"daily_title_text_date": "E, dd MMM",
"daily_title_text_date_year": "E, dd MMM, yyyy",
@@ -75,6 +76,7 @@
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "App und Server sind aktuell",
"profile_drawer_sign_out": "Abmelden",
"profile_drawer_settings": "Einstellungen",
"search_bar_hint": "Durchsuche deine Fotos",
"search_page_no_objects": "Keine Objektinformationen verfügbar",
"search_page_no_places": "Keine Informationen über Orte verfügbar",
@@ -97,10 +99,28 @@
"tab_controller_nav_photos": "Fotos",
"tab_controller_nav_search": "Suche",
"tab_controller_nav_sharing": "Teilen",
"tab_controller_nav_library": "Bibliothek",
"version_announcement_overlay_ack": "Ich habe verstanden",
"version_announcement_overlay_release_notes": "Änderungsprotokoll",
"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_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 liefert die beste Bildqualität, ist dafür aber langsamer beim Laden."
}

View File

@@ -16,10 +16,23 @@
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"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_controller_page_albums": "Backup Albums",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selected: ",
"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_created": "Created on: {}",
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
@@ -48,7 +61,7 @@
"control_bottom_app_bar_delete": "Delete",
"create_shared_album_page_share": "Share",
"create_shared_album_page_create": "Create",
"create_shared_album_page_share_add_assets": "ADD ASSETS",
"create_shared_album_page_share_add_assets": "ADD PHOTOS",
"create_shared_album_page_share_select_photos": "Select Photos",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
@@ -75,7 +88,8 @@
"login_form_save_login": "Stay logged in",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_sign_out": "Sign Out",
"profile_drawer_sign_out": "Sign out",
"profile_drawer_settings": "Settings",
"search_bar_hint": "Search your photos",
"search_page_no_objects": "No Objects Info Available",
"search_page_no_places": "No Places Info Available",
@@ -104,5 +118,22 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Shared",
"library_page_albums": "Albums",
"library_page_new_album": "New album",
"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": "The three-stage loading delivers the best quality image in exchange for a slower loading speed"
}

View File

@@ -19,6 +19,8 @@ PODS:
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
- share_plus (0.0.1):
- Flutter
- shared_preferences_ios (0.0.1):
- Flutter
- sqflite (0.0.2):
@@ -40,6 +42,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -67,6 +70,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_ios/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
sqflite:
@@ -88,6 +93,7 @@ SPEC CHECKSUMS:
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196

View File

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

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.20.0</string>
<string>1.21.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>38</string>
<string>40</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -66,8 +66,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>

View File

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

View File

@@ -5,34 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000213">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000205">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.088407">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.360401">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="22.635867">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.012696">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.376681">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.378836">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="91.762747">
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="49.149884">
<failure message="/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:30:in `block (2 levels) in parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/gems/fastlane-2.207.0/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/bin/fastlane:25:in `load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.207.0/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Error uploading ipa file: &#10; [Transporter Error Output]: ERROR ITMS-90186: Invalid Pre-Release Train. The train version &apos;1.19.0&apos; is closed for new build submissions
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="98.18403">
</testcase>

View File

@@ -16,3 +16,6 @@ const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
// Github Release Info
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
// User Setting Info
const String userSettingInfoBox = "immichUserSettingInfoBox";

View File

@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
const immichBackgroundColor = Color(0xFFf6f8fe);
Color immichBackgroundColor = const Color(0xFFf6f8fe);
Color immichDarkBackgroundColor = const Color.fromARGB(255, 0, 0, 0);
Color immichDarkThemePrimaryColor = const Color.fromARGB(255, 173, 203, 250);

View File

@@ -0,0 +1,17 @@
import 'dart:ui';
const List<Locale> locales = [
// Default locale
Locale('en', 'US'),
// Additional locales
Locale('da', 'DK'),
Locale('de', 'DE'),
Locale('es', 'ES'),
Locale('fi', 'FI'),
Locale('fr', 'FR'),
Locale('it', 'IT'),
Locale('ja', 'JP'),
Locale('pl', 'PL')
];
const String translationsPath = 'assets/i18n';

View File

@@ -1,9 +1,15 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
@@ -17,6 +23,7 @@ import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'constants/hive_box.dart';
void main() async {
@@ -29,6 +36,7 @@ void main() async {
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
await Hive.openBox(hiveGithubReleaseInfoBox);
await Hive.openBox(userSettingInfoBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
@@ -38,21 +46,18 @@ void main() async {
await EasyLocalization.ensureInitialized();
var locales = const [
// Default locale
Locale('en', 'US'),
// Additional locales
Locale('da', 'DK'),
Locale('de', 'DE'),
Locale('es', 'ES'),
Locale('fr', 'FR'),
Locale('it', 'IT'),
];
if (kReleaseMode && Platform.isAndroid) {
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch (e) {
debugPrint("Error setting high refresh rate: $e");
}
}
runApp(
EasyLocalization(
supportedLocales: locales,
path: 'assets/i18n',
path: translationsPath,
useFallbackTranslations: true,
fallbackLocale: locales.first,
child: const ProviderScope(child: ImmichApp()),
@@ -79,6 +84,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
if (isAuthenticated) {
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
@@ -117,8 +123,11 @@ class ImmichAppState extends ConsumerState<ImmichApp>
@override
initState() {
super.initState();
initApp().then((_) => debugPrint("App Init Completed"));
WidgetsBinding.instance.addPostFrameCallback((_) {
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
});
}
@override
@@ -142,23 +151,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.indigo,
fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
),
scaffoldBackgroundColor: immichBackgroundColor,
appBarTheme: const AppBarTheme(
backgroundColor: immichBackgroundColor,
foregroundColor: Colors.indigo,
elevation: 1,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
),
themeMode: ref.watch(immichThemeProvider),
darkTheme: immichDarkTheme,
theme: immichLightTheme,
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
navigatorObservers: () => [TabNavigationObserver(ref: ref)],

View File

@@ -14,6 +14,8 @@ class AlbumActionOutlinedButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: OutlinedButton.icon(
@@ -22,19 +24,23 @@ class AlbumActionOutlinedButton extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
side: const BorderSide(
side: BorderSide(
width: 1,
color: Color.fromARGB(255, 215, 215, 215),
color: isDarkTheme
? const Color.fromARGB(255, 63, 63, 63)
: const Color.fromARGB(255, 206, 206, 206),
),
),
icon: Icon(iconData, size: 15),
icon: Icon(
iconData,
size: 15,
color: Theme.of(context).primaryColor,
),
label: Text(
labelText,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
onPressed: onPressed,
),

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -15,6 +16,8 @@ class AlbumThumbnailCard extends StatelessWidget {
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
final cardSize = MediaQuery.of(context).size.width / 2 - 18;
return GestureDetector(
onTap: () {
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
@@ -27,8 +30,8 @@ class AlbumThumbnailCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: FadeInImage(
width: MediaQuery.of(context).size.width / 2 - 18,
height: MediaQuery.of(context).size.width / 2 - 18,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
image: NetworkImage(
@@ -43,11 +46,13 @@ class AlbumThumbnailCard extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
album.albumName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
child: SizedBox(
width: cardSize,
child: Text(
album.albumName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
@@ -55,18 +60,20 @@ class AlbumThumbnailCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${album.assets.length} item${album.assets.length > 1 ? 's' : ''}',
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 10,
fontSize: 12,
),
),
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
' · Shared',
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 10,
fontSize: 12,
),
)
).tr()
],
)
],

View File

@@ -19,6 +19,8 @@ class AlbumTitleTextField extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return TextField(
onChanged: (v) {
if (v.isEmpty) {
@@ -51,7 +53,10 @@ class AlbumTitleTextField extends ConsumerWidget {
albumTitleController.clear();
isAlbumTitleEmpty.value = true;
},
icon: const Icon(Icons.cancel_rounded),
icon: Icon(
Icons.cancel_rounded,
color: Theme.of(context).primaryColor,
),
splashRadius: 10,
)
: null,
@@ -65,7 +70,9 @@ class AlbumTitleTextField extends ConsumerWidget {
),
hintText: 'share_add_title'.tr(),
focusColor: Colors.grey[300],
fillColor: Colors.grey[200],
fillColor: isDarkTheme
? const Color.fromARGB(255, 32, 33, 35)
: Colors.grey[200],
filled: isAlbumTitleTextFieldFocus.value,
),
);

View File

@@ -150,7 +150,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void _buildBottomSheet() {
showModalBottomSheet(
backgroundColor: immichBackgroundColor,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {

View File

@@ -18,6 +18,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final titleTextEditController =
useTextEditingController(text: albumInfo.albumName);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
void onFocusModeChange() {
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
@@ -65,7 +66,10 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
onPressed: () {
titleTextEditController.clear();
},
icon: const Icon(Icons.cancel_rounded),
icon: Icon(
Icons.cancel_rounded,
color: Theme.of(context).primaryColor,
),
splashRadius: 10,
)
: null,
@@ -78,7 +82,9 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
borderRadius: BorderRadius.circular(10),
),
focusColor: Colors.grey[300],
fillColor: Colors.grey[200],
fillColor: isDarkTheme
? const Color.fromARGB(255, 32, 33, 35)
: Colors.grey[200],
filled: titleFocusNode.hasFocus,
hintText: 'share_add_title'.tr(),
),

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class AlbumViewerThumbnail extends HookConsumerWidget {
@@ -24,8 +25,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
@@ -37,7 +37,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
GalleryViewerRoute(
asset: asset,
assetList: assetList,
thumbnailRequestUrl: thumbnailRequestUrl,
),
);
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class SharedAlbumThumbnailImage extends HookConsumerWidget {
@@ -17,8 +18,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
return GestureDetector(
onTap: () {
@@ -32,7 +31,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
height: 500,
memCacheHeight: 500,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
imageUrl: getThumbnailUrl(asset),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) =>

View File

@@ -35,13 +35,7 @@ class SharingSliverAppBar extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: TextButton.icon(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).primaryColor.withAlpha(20),
),
// foregroundColor: MaterialStateProperty.all(Colors.white),
),
child: ElevatedButton.icon(
onPressed: () {
AutoRouter.of(context)
.push(CreateAlbumRoute(isSharedAlbum: true));
@@ -52,8 +46,12 @@ class SharingSliverAppBar extends StatelessWidget {
),
label: const Text(
"sharing_silver_appbar_create_shared_album",
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
// color: Theme.of(context).primaryColor,
),
).tr(),
),
),
@@ -61,13 +59,7 @@ class SharingSliverAppBar extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: TextButton.icon(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).primaryColor.withAlpha(20),
),
// foregroundColor: MaterialStateProperty.all(Colors.white),
),
child: ElevatedButton.icon(
onPressed: null,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
@@ -75,8 +67,11 @@ class SharingSliverAppBar extends StatelessWidget {
),
label: const Text(
"sharing_silver_appbar_share_partner",
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
),
maxLines: 1,
).tr(),
),
),

View File

@@ -3,7 +3,6 @@ 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/constants/immich_colors.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
@@ -203,7 +202,7 @@ class AlbumViewerPage extends HookConsumerWidget {
assetList: albumInfo.assets,
);
},
childCount: albumInfo.assets.length,
childCount: albumInfo.assetCount,
),
),
);
@@ -242,7 +241,7 @@ class AlbumViewerPage extends HookConsumerWidget {
titleFocusNode.unfocus();
},
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
@@ -255,7 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget {
minHeight: 50,
maxHeight: 50,
child: Container(
color: immichBackgroundColor,
color: Theme.of(context).scaffoldBackgroundColor,
child: _buildControlButton(albumInfo),
),
),

View File

@@ -43,7 +43,7 @@ class AssetSelectionPage extends HookConsumerWidget {
return Stack(
children: [
DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(

View File

@@ -27,6 +27,7 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleEmpty = useState(true);
final selectedAssets =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
_showSelectUserPage() {
AutoRouter.of(context).push(const SelectUserForSharingRoute());
@@ -37,8 +38,10 @@ class CreateAlbumPage extends HookConsumerWidget {
isAlbumTitleTextFieldFocus.value = false;
if (albumTitleController.text.isEmpty) {
albumTitleController.text = 'Untitled';
ref.watch(albumTitleProvider.notifier).setAlbumTitle('Untitled');
albumTitleController.text = 'create_album_page_untitled'.tr();
ref
.watch(albumTitleProvider.notifier)
.setAlbumTitle('create_album_page_untitled'.tr());
}
}
@@ -73,9 +76,12 @@ class CreateAlbumPage extends HookConsumerWidget {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 200, left: 18),
child: const Text(
child: Text(
'create_shared_album_page_share_add_assets',
style: TextStyle(fontSize: 12),
style: Theme.of(context).textTheme.headline2?.copyWith(
fontSize: 12,
fontWeight: FontWeight.normal,
),
).tr(),
),
);
@@ -94,24 +100,28 @@ class CreateAlbumPage extends HookConsumerWidget {
alignment: Alignment.centerLeft,
padding:
const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
side: const BorderSide(
color: Color.fromARGB(255, 206, 206, 206),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 63, 63, 63)
: const Color.fromARGB(255, 206, 206, 206),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
onPressed: _onSelectPhotosButtonPressed,
icon: const Icon(Icons.add_rounded),
icon: Icon(
Icons.add_rounded,
color: Theme.of(context).primaryColor,
),
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
'create_shared_album_page_share_select_photos',
style: TextStyle(
fontSize: 16,
color: Colors.grey[700],
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
@@ -188,6 +198,7 @@ class CreateAlbumPage extends HookConsumerWidget {
appBar: AppBar(
elevation: 0,
centerTitle: false,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
leading: IconButton(
onPressed: () {
ref.watch(assetSelectionProvider.notifier).removeAll();
@@ -195,9 +206,11 @@ class CreateAlbumPage extends HookConsumerWidget {
},
icon: const Icon(Icons.close_rounded),
),
title: const Text(
title: Text(
'share_create_album',
style: TextStyle(color: Colors.black),
style: Theme.of(context).textTheme.headline2?.copyWith(
color: Theme.of(context).primaryColor,
),
).tr(),
actions: [
if (isSharedAlbum)
@@ -207,8 +220,9 @@ class CreateAlbumPage extends HookConsumerWidget {
: null,
child: Text(
'create_shared_album_page_share'.tr(),
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
@@ -232,9 +246,9 @@ class CreateAlbumPage extends HookConsumerWidget {
child: CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 5,
automaticallyImplyLeading: false,
// leading: Container(),
pinned: true,
floating: false,
bottom: PreferredSize(

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -22,7 +23,7 @@ class LibraryPage extends HookConsumerWidget {
);
Widget _buildAppBar() {
return SliverAppBar(
return const SliverAppBar(
centerTitle: true,
floating: true,
pinned: false,
@@ -34,7 +35,6 @@ class LibraryPage extends HookConsumerWidget {
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
);
@@ -66,15 +66,14 @@ class LibraryPage extends HookConsumerWidget {
),
),
),
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
"New album",
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'library_page_new_album',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
).tr(),
)
],
),
@@ -85,13 +84,13 @@ class LibraryPage extends HookConsumerWidget {
body: CustomScrollView(
slivers: [
_buildAppBar(),
const SliverToBoxAdapter(
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(12.0),
child: Text(
"Albums",
padding: const EdgeInsets.all(12.0),
child: const Text(
'library_page_albums',
style: TextStyle(fontWeight: FontWeight.bold),
),
).tr(),
),
),
SliverPadding(

View File

@@ -136,9 +136,9 @@ class SelectUserForSharingPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text(
title: Text(
'share_invite',
style: TextStyle(color: Colors.black),
style: TextStyle(color: Theme.of(context).primaryColor),
).tr(),
elevation: 0,
centerTitle: false,
@@ -150,11 +150,18 @@ class SelectUserForSharingPage extends HookConsumerWidget {
),
actions: [
TextButton(
style: TextButton.styleFrom(
primary: Theme.of(context).primaryColor,
),
onPressed:
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
child: const Text(
"share_create_album",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
// color: Theme.of(context).primaryColor,
),
).tr(),
)
],

View File

@@ -61,11 +61,9 @@ class SharingPage extends HookConsumerWidget {
sharedAlbums[index].albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
onTap: () {
AutoRouter.of(context)
@@ -87,7 +85,7 @@ class SharingPage extends HookConsumerWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // if you need this
side: const BorderSide(
color: Colors.black12,
color: Colors.grey,
width: 1,
),
),
@@ -97,30 +95,26 @@ class SharingPage extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 5.0, bottom: 5),
const Padding(
padding: EdgeInsets.only(left: 5.0, bottom: 5),
child: Icon(
Icons.offline_share_outlined,
size: 50,
color: Theme.of(context).primaryColor.withAlpha(200),
// color: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'sharing_page_empty_list',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.headline3,
).tr(),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'sharing_page_description',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
style: Theme.of(context).textTheme.bodyMedium,
).tr(),
),
],

View File

@@ -1,15 +1,19 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService;
final ShareService _shareService;
ImageViewerStateNotifier(this._imageViewerService)
ImageViewerStateNotifier(this._imageViewerService, this._shareService)
: super(
ImageViewerPageState(
downloadAssetStatus: DownloadAssetStatus.idle,
@@ -42,9 +46,23 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
}
void shareAsset(AssetResponseDto asset, BuildContext context) async {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService
.shareAsset(asset)
.then((_) => Navigator.of(buildContext).pop());
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerStateNotifier(ref.watch(imageViewerServiceProvider))),
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider), ref.watch(shareServiceProvider))),
);

View File

@@ -3,7 +3,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, full }
enum _RemoteImageStatus { empty, thumbnail, preview, full }
class _RemotePhotoViewState extends State<RemotePhotoView> {
late CachedNetworkImageProvider _imageProvider;
@@ -15,13 +15,16 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override
Widget build(BuildContext context) {
bool allowMoving = _status == _RemoteImageStatus.full;
return PhotoView(
imageProvider: _imageProvider,
minScale: PhotoViewComputedScale.contained,
maxScale: allowMoving ? 1.0 : PhotoViewComputedScale.contained,
enablePanAlways: true,
scaleStateChangedCallback: _scaleStateChanged,
onScaleEnd: _onScaleListener,
return IgnorePointer(
ignoring: !allowMoving,
child: PhotoView(
imageProvider: _imageProvider,
minScale: PhotoViewComputedScale.contained,
enablePanAlways: true,
scaleStateChangedCallback: _scaleStateChanged,
onScaleEnd: _onScaleListener,
),
);
}
@@ -52,6 +55,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.isZoomedFunction();
}
void _fireStartLoadingEvent() {
widget.onLoadingStart();
}
void _fireFinishedLoadingEvent() {
widget.onLoadingCompleted();
}
CachedNetworkImageProvider _authorizedImageProvider(String url) {
return CachedNetworkImageProvider(
url,
@@ -64,14 +75,25 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
_RemoteImageStatus newStatus,
CachedNetworkImageProvider provider,
) {
// Transition to same status is forbidden
if (_status == newStatus) return;
// Transition full -> thumbnail is forbidden
if (_status == _RemoteImageStatus.full &&
newStatus == _RemoteImageStatus.thumbnail) return;
if (_status == _RemoteImageStatus.preview &&
newStatus == _RemoteImageStatus.thumbnail) return;
if (_status == _RemoteImageStatus.full &&
newStatus == _RemoteImageStatus.preview) return;
if (!mounted) return;
if (newStatus != _RemoteImageStatus.full) {
_fireStartLoadingEvent();
} else {
_fireFinishedLoadingEvent();
}
setState(() {
_status = newStatus;
_imageProvider = provider;
@@ -92,6 +114,16 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}),
);
if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider =
_authorizedImageProvider(widget.previewUrl!);
previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.preview, previewProvider);
}),
);
}
CachedNetworkImageProvider fullProvider =
_authorizedImageProvider(widget.imageUrl);
fullProvider.resolve(const ImageConfiguration()).addListener(
@@ -118,11 +150,17 @@ class RemotePhotoView extends StatefulWidget {
required this.isZoomedListener,
required this.onSwipeDown,
required this.onSwipeUp,
this.previewUrl,
required this.onLoadingCompleted,
required this.onLoadingStart,
}) : super(key: key);
final String thumbnailUrl;
final String imageUrl;
final String authToken;
final String? previewUrl;
final Function onLoadingCompleted;
final Function onLoadingStart;
final void Function() onSwipeDown;
final void Function() onSwipeUp;

View File

@@ -11,11 +11,15 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onSharePressed,
this.loading = false
}) : super(key: key);
final AssetResponseDto asset;
final Function onMoreInfoPressed;
final Function onDownloadPressed;
final Function onSharePressed;
final bool loading;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -35,6 +39,14 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
),
),
actions: [
if (loading) Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 15.0),
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2.0),
),
) ,
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
@@ -53,6 +65,14 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onSharePressed();
},
icon: const Icon(Icons.share),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,

View File

@@ -11,36 +11,51 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/home/services/asset.service.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:openapi/api.dart';
// ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget {
late List<AssetResponseDto> assetList;
final AssetResponseDto asset;
final String thumbnailRequestUrl;
GalleryViewerPage({
Key? key,
required this.assetList,
required this.asset,
required this.thumbnailRequestUrl,
}) : super(key: key);
AssetResponseDto? assetDetail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final Box<dynamic> box = Hive.box(userInfoBox);
final appSettingService = ref.watch(appSettingsServiceProvider);
final threeStageLoading = useState(false);
final loading = useState(false);
final isZoomed = useState<bool>(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
int indexOfAsset = assetList.indexOf(asset);
@override
void initState(int index) {
indexOfAsset = index;
}
PageController controller =
PageController(initialPage: assetList.indexOf(asset));
useEffect(
() {
threeStageLoading.value = appSettingService
.getSetting<bool>(AppSettingsEnum.threeStageLoading);
return null;
},
[],
);
@override
initState(int index) {
indexOfAsset = index;
}
getAssetExif() async {
assetDetail = await ref
.watch(assetServiceProvider)
@@ -59,9 +74,6 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
final isZoomed = useState<bool>(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
//make isZoomed listener call instead
void isZoomedMethod() {
if (isZoomedListener.value) {
@@ -74,6 +86,7 @@ class GalleryViewerPage extends HookConsumerWidget {
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
loading: loading.value,
asset: assetList[indexOfAsset],
onMoreInfoPressed: () {
showInfo();
@@ -83,6 +96,11 @@ class GalleryViewerPage extends HookConsumerWidget {
.watch(imageViewerStateProvider.notifier)
.downloadAsset(assetList[indexOfAsset], context);
},
onSharePressed: () {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset], context);
},
),
body: SafeArea(
child: PageView.builder(
@@ -95,18 +113,19 @@ class GalleryViewerPage extends HookConsumerWidget {
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
initState(index);
getAssetExif();
if (assetList[index].type == AssetTypeEnum.IMAGE) {
return ImageViewerPage(
thumbnailUrl:
'${box.get(serverEndpointKey)}/asset/thumbnail/${assetList[index].id}',
imageUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}&isThumb=false',
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
onLoadingCompleted: () => {},
onLoadingStart: () => {},
asset: assetList[index],
heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value,
);
} else {
return SwipeDetector(

View File

@@ -8,30 +8,34 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final AssetResponseDto asset;
final String authToken;
final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading;
ImageViewerPage({
Key? key,
required this.imageUrl,
required this.heroTag,
required this.thumbnailUrl,
required this.asset,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading,
}) : super(key: key);
AssetResponseDto? assetDetail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus =
@@ -68,13 +72,18 @@ class ImageViewerPage extends HookConsumerWidget {
child: Hero(
tag: heroTag,
child: RemotePhotoView(
thumbnailUrl: thumbnailUrl,
imageUrl: imageUrl,
thumbnailUrl: getThumbnailUrl(asset),
imageUrl: getImageUrl(asset),
previewUrl: threeStageLoading
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
: null,
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(),
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
),
),
),

View File

@@ -0,0 +1,382 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:photo_manager/photo_manager.dart';
final backgroundServiceProvider = Provider(
(ref) => BackgroundService(),
);
/// Background backup service
class BackgroundService {
static const String _portNameLock = "immichLock";
BackgroundService();
static const MethodChannel _foregroundChannel =
MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel');
bool _isForegroundInitialized = false;
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
bool _canceledBySystem = false;
int _wantsLockTime = 0;
bool _hasLock = false;
SendPort? _waitingIsolate;
ReceivePort? _rp;
bool get isForegroundInitialized {
return _isForegroundInitialized;
}
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
}
Future<bool> _initialize() async {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
var result = await _foregroundChannel
.invokeMethod('initialize', [callback.toRawHandle()]);
_isForegroundInitialized = true;
return result;
}
/// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() &&
await startService(keepExisting: true);
}
/// Enqueues the background service
Future<bool> startService({
bool immediate = false,
bool keepExisting = false,
bool requireUnmetered = true,
bool requireCharging = false,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod(
'start',
[immediate, keepExisting, requireUnmetered, requireCharging, title],
);
return ok;
} catch (error) {
return false;
}
}
/// Cancels the background service (if currently running) and removes it from work queue
Future<bool> stopService() async {
if (!Platform.isAndroid) {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final ok = await _foregroundChannel.invokeMethod('stop');
return ok;
} catch (error) {
return false;
}
}
/// Returns `true` if the background service is enabled
Future<bool> isBackgroundBackupEnabled() async {
if (!Platform.isAndroid) {
return false;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) {
return false;
}
}
/// Opens an activity to let the user disable battery optimizations for Immich
Future<bool> disableBatteryOptimizations() async {
if (!Platform.isAndroid) {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String message =
"backup_background_service_disable_battery_optimizations".tr();
return await _foregroundChannel.invokeMethod(
'disableBatteryOptimizations',
message,
);
} catch (error) {
return false;
}
}
/// Updates the notification shown by the background service
Future<bool> updateNotification({
String title = "Immich",
String? content,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return await _backgroundChannel
.invokeMethod('updateNotification', [title, content]);
}
} catch (error) {
debugPrint("[updateNotification] failed to communicate with plugin");
}
return Future.value(false);
}
/// Shows a new priority notification
Future<bool> showErrorNotification(
String title,
String content,
) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return await _backgroundChannel
.invokeMethod('showError', [title, content]);
}
} catch (error) {
debugPrint("[showErrorNotification] failed to communicate with plugin");
}
return Future.value(false);
}
/// await to ensure this thread (foreground or background) has exclusive access
Future<bool> acquireLock() async {
if (!Platform.isAndroid) {
return true;
}
final int lockTime = Timeline.now;
_wantsLockTime = lockTime;
final ReceivePort rp = ReceivePort(_portNameLock);
_rp = rp;
final SendPort sp = rp.sendPort;
while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
try {
await _checkLockReleasedWithHeartbeat(lockTime);
} catch (error) {
return false;
}
if (_wantsLockTime != lockTime) {
return false;
}
}
_hasLock = true;
rp.listen(_heartbeatListener);
return true;
}
Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
if (other != null) {
final ReceivePort tempRp = ReceivePort();
final SendPort tempSp = tempRp.sendPort;
final bs = tempRp.asBroadcastStream();
while (_wantsLockTime == lockTime) {
other.send(tempSp);
final dynamic answer = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => null);
if (_wantsLockTime != lockTime) {
break;
}
if (answer == null) {
// other isolate failed to answer, assuming it exited without releasing the lock
if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
IsolateNameServer.removePortNameMapping(_portNameLock);
}
break;
} else if (answer == true) {
// other isolate released the lock
break;
} else if (answer == false) {
// other isolate is still active
}
final dynamic isFinished = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => false);
if (isFinished == true) {
break;
}
}
tempRp.close();
}
}
void _heartbeatListener(dynamic msg) {
if (msg is SendPort) {
_waitingIsolate = msg;
msg.send(false);
}
}
/// releases the exclusive access lock
void releaseLock() {
if (!Platform.isAndroid) {
return;
}
_wantsLockTime = 0;
if (_hasLock) {
IsolateNameServer.removePortNameMapping(_portNameLock);
_waitingIsolate?.send(true);
_waitingIsolate = null;
_hasLock = false;
}
_rp?.close();
_rp = null;
}
void _setupBackgroundCallHandler() {
_backgroundChannel.setMethodCallHandler(_callHandler);
_isBackgroundInitialized = true;
_backgroundChannel.invokeMethod('initialized');
}
Future<bool> _callHandler(MethodCall call) async {
switch (call.method) {
case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations();
try {
final bool hasAccess = await acquireLock();
if (!hasAccess) {
debugPrint("[_callHandler] could acquire lock, exiting");
return false;
}
await translationsLoaded;
return await _onAssetsChanged();
} catch (error) {
debugPrint(error.toString());
return false;
} finally {
await Hive.close();
releaseLock();
}
case "systemStop":
_canceledBySystem = true;
_cancellationToken?.cancel();
return true;
default:
debugPrint("Unknown method ${call.method}");
return false;
}
}
Future<bool> _onAssetsChanged() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService);
final Box<HiveBackupAlbums> box =
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo == null) {
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
if (_canceledBySystem) {
return false;
}
final List<AssetEntity> toUpload =
await backupService.getAssetsToBackup(backupAlbumInfo);
if (_canceledBySystem) {
return false;
}
if (toUpload.isEmpty) {
return true;
}
_cancellationToken = CancellationToken();
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
_onAssetUploaded,
_onProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
if (ok) {
await box.put(
backupInfoKey,
backupAlbumInfo,
);
}
return ok;
}
void _onAssetUploaded(String deviceAssetId, String deviceId) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
}
void _onProgress(int sent, int total) {}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
showErrorNotification(
"backup_background_service_upload_failure_notification"
.tr(args: [errorAssetInfo.fileName]),
errorAssetInfo.errorMessage,
);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
updateNotification(
title: "backup_background_service_in_progress_notification".tr(),
content: "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]),
);
}
}
/// entry point called by Kotlin/Java code; needs to be a top-level function
void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService();
backgroundService._setupBackgroundCallHandler();
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
import 'package:easy_localization/src/asset_loader.dart';
import 'package:easy_localization/src/easy_localization_controller.dart';
import 'package:easy_localization/src/localization.dart';
import 'package:immich_mobile/constants/locales.dart';
/// Workaround to manually load translations in another Isolate
Future<bool> loadTranslations() async {
await EasyLocalizationController.initEasyLocation();
final controller = EasyLocalizationController(
supportedLocales: locales,
useFallbackTranslations: true,
saveLocale: true,
assetLoader: const RootBundleAssetLoader(),
path: translationsPath,
useOnlyLangCode: false,
onLoadError: (e) => debugPrint(e.toString()),
fallbackLocale: locales.first,
);
await controller.loadTranslations();
return Localization.load(controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations);
}

View File

@@ -4,35 +4,45 @@ import 'package:photo_manager/photo_manager.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final DateTime? lastBackup;
final Uint8List? thumbnailData;
AvailableAlbum({
required this.albumEntity,
this.lastBackup,
this.thumbnailData,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
lastBackup: lastBackup ?? this.lastBackup,
thumbnailData: thumbnailData ?? this.thumbnailData,
);
}
String get name => albumEntity.name;
int get assetCount => albumEntity.assetCount;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum &&
other.albumEntity == albumEntity &&
other.thumbnailData == thumbnailData;
return other is AvailableAlbum && other.albumEntity == albumEntity;
}
@override
int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
int get hashCode => albumEntity.hashCode;
}

View File

@@ -6,7 +6,7 @@ import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
enum BackUpProgressEnum { idle, inProgress, done }
enum BackUpProgressEnum { idle, inProgress, inBackground, done }
class BackUpState {
// enum
@@ -15,11 +15,14 @@ class BackUpState {
final double progressInPercentage;
final CancellationToken cancelToken;
final ServerInfoResponseDto serverInfo;
final bool backgroundBackup;
final bool backupRequireWifi;
final bool backupRequireCharging;
/// All available albums on the device
final List<AvailableAlbum> availableAlbums;
final Set<AssetPathEntity> selectedBackupAlbums;
final Set<AssetPathEntity> excludedBackupAlbums;
final Set<AvailableAlbum> selectedBackupAlbums;
final Set<AvailableAlbum> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets;
@@ -36,6 +39,9 @@ class BackUpState {
required this.progressInPercentage,
required this.cancelToken,
required this.serverInfo,
required this.backgroundBackup,
required this.backupRequireWifi,
required this.backupRequireCharging,
required this.availableAlbums,
required this.selectedBackupAlbums,
required this.excludedBackupAlbums,
@@ -50,9 +56,12 @@ class BackUpState {
double? progressInPercentage,
CancellationToken? cancelToken,
ServerInfoResponseDto? serverInfo,
bool? backgroundBackup,
bool? backupRequireWifi,
bool? backupRequireCharging,
List<AvailableAlbum>? availableAlbums,
Set<AssetPathEntity>? selectedBackupAlbums,
Set<AssetPathEntity>? excludedBackupAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
@@ -63,6 +72,10 @@ class BackUpState {
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo,
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
backupRequireCharging:
backupRequireCharging ?? this.backupRequireCharging,
availableAlbums: availableAlbums ?? this.availableAlbums,
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
@@ -75,7 +88,7 @@ class BackUpState {
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
@@ -89,6 +102,9 @@ class BackUpState {
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
other.backgroundBackup == backgroundBackup &&
other.backupRequireWifi == backupRequireWifi &&
other.backupRequireCharging == backupRequireCharging &&
collectionEquals(other.availableAlbums, availableAlbums) &&
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
@@ -107,6 +123,9 @@ class BackUpState {
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
backgroundBackup.hashCode ^
backupRequireWifi.hashCode ^
backupRequireCharging.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^

View File

@@ -13,9 +13,17 @@ class HiveBackupAlbums {
@HiveField(1)
List<String> excludedAlbumsIds;
@HiveField(2, defaultValue: [])
List<DateTime> lastSelectedBackupTime;
@HiveField(3, defaultValue: [])
List<DateTime> lastExcludedBackupTime;
HiveBackupAlbums({
required this.selectedAlbumIds,
required this.excludedAlbumsIds,
required this.lastSelectedBackupTime,
required this.lastExcludedBackupTime,
});
@override
@@ -25,10 +33,16 @@ class HiveBackupAlbums {
HiveBackupAlbums copyWith({
List<String>? selectedAlbumIds,
List<String>? excludedAlbumsIds,
List<DateTime>? lastSelectedBackupTime,
List<DateTime>? lastExcludedBackupTime,
}) {
return HiveBackupAlbums(
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
lastSelectedBackupTime:
lastSelectedBackupTime ?? this.lastSelectedBackupTime,
lastExcludedBackupTime:
lastExcludedBackupTime ?? this.lastExcludedBackupTime,
);
}
@@ -37,6 +51,8 @@ class HiveBackupAlbums {
result.addAll({'selectedAlbumIds': selectedAlbumIds});
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
result.addAll({'lastSelectedBackupTime': lastSelectedBackupTime});
result.addAll({'lastExcludedBackupTime': lastExcludedBackupTime});
return result;
}
@@ -45,6 +61,10 @@ class HiveBackupAlbums {
return HiveBackupAlbums(
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
lastSelectedBackupTime:
List<DateTime>.from(map['lastSelectedBackupTime']),
lastExcludedBackupTime:
List<DateTime>.from(map['lastExcludedBackupTime']),
);
}
@@ -60,9 +80,15 @@ class HiveBackupAlbums {
return other is HiveBackupAlbums &&
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
listEquals(other.excludedAlbumsIds, excludedAlbumsIds) &&
listEquals(other.lastSelectedBackupTime, lastSelectedBackupTime) &&
listEquals(other.lastExcludedBackupTime, lastExcludedBackupTime);
}
@override
int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
int get hashCode =>
selectedAlbumIds.hashCode ^
excludedAlbumsIds.hashCode ^
lastSelectedBackupTime.hashCode ^
lastExcludedBackupTime.hashCode;
}

View File

@@ -19,17 +19,25 @@ class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
return HiveBackupAlbums(
selectedAlbumIds: (fields[0] as List).cast<String>(),
excludedAlbumsIds: (fields[1] as List).cast<String>(),
lastSelectedBackupTime:
fields[2] == null ? [] : (fields[2] as List).cast<DateTime>(),
lastExcludedBackupTime:
fields[3] == null ? [] : (fields[3] as List).cast<DateTime>(),
);
}
@override
void write(BinaryWriter writer, HiveBackupAlbums obj) {
writer
..writeByte(2)
..writeByte(4)
..writeByte(0)
..write(obj.selectedAlbumIds)
..writeByte(1)
..write(obj.excludedAlbumsIds);
..write(obj.excludedAlbumsIds)
..writeByte(2)
..write(obj.lastSelectedBackupTime)
..writeByte(3)
..write(obj.lastExcludedBackupTime);
}
@override

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -9,9 +11,11 @@ import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.d
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -21,6 +25,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._backupService,
this._serverInfoService,
this._authState,
this._backgroundService,
this.ref,
) : super(
BackUpState(
@@ -28,6 +33,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
allAssetsInDatabase: const [],
progressInPercentage: 0,
cancelToken: CancellationToken(),
backgroundBackup: false,
backupRequireWifi: true,
backupRequireCharging: false,
serverInfo: ServerInfoResponseDto(
diskAvailable: "0",
diskAvailableRaw: 0,
@@ -56,6 +64,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
final BackgroundService _backgroundService;
final Ref ref;
///
@@ -66,7 +75,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AssetPathEntity album) {
void addAlbumForBackup(AvailableAlbum album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
@@ -76,7 +85,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void addExcludedAlbumForBackup(AssetPathEntity album) {
void addExcludedAlbumForBackup(AvailableAlbum album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
@@ -85,8 +94,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void removeAlbumForBackup(AssetPathEntity album) {
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
void removeAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
@@ -94,8 +103,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void removeExcludedAlbumForBackup(AssetPathEntity album) {
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
void removeExcludedAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
@@ -103,6 +112,50 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updateBackupAssetCount();
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
bool? requireCharging,
required void Function(String msg) onError,
}) async {
assert(enabled != null || requireWifi != null || requireCharging != null);
if (Platform.isAndroid) {
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharing = state.backupRequireCharging;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
);
if (state.backgroundBackup) {
if (!wasEnabled) {
await _backgroundService.disableBatteryOptimizations();
}
final bool success = await _backgroundService.stopService() &&
await _backgroundService.startService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
);
if (!success) {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharing,
);
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.stopService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");
}
}
}
}
///
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
@@ -144,6 +197,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
defaultValue: HiveBackupAlbums(
selectedAlbumIds: [],
excludedAlbumsIds: [],
lastSelectedBackupTime: [],
lastExcludedBackupTime: [],
),
);
@@ -173,6 +228,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
HiveBackupAlbums(
selectedAlbumIds: [albumHasAllAssets.id],
excludedAlbumsIds: [],
lastSelectedBackupTime: [
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
],
lastExcludedBackupTime: [],
),
);
@@ -181,19 +240,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Generate AssetPathEntity from id to add to local state
try {
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
state = state.copyWith(
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset},
Set<AvailableAlbum> selectedAlbums = {};
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
var albumAsset =
await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
selectedAlbums.add(
AvailableAlbum(
albumEntity: albumAsset,
lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i
? backupAlbumInfo.lastSelectedBackupTime[i]
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
),
);
}
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
state = state.copyWith(
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset},
Set<AvailableAlbum> excludedAlbums = {};
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
var albumAsset =
await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
excludedAlbums.add(
AvailableAlbum(
albumEntity: albumAsset,
lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i
? backupAlbumInfo.lastExcludedBackupTime[i]
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
),
);
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
} catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e");
}
@@ -209,14 +286,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) {
var assets =
await album.getAssetListRange(start: 0, end: album.assetCount);
var assets = await album.albumEntity
.getAssetListRange(start: 0, end: album.assetCount);
assetsFromSelectedAlbums.addAll(assets);
}
for (var album in state.excludedBackupAlbums) {
var assets =
await album.getAssetListRange(start: 0, end: album.assetCount);
var assets = await album.albumEntity
.getAssetListRange(start: 0, end: album.assetCount);
assetsFromExcludedAlbums.addAll(assets);
}
@@ -263,12 +340,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// and then update the UI according to those information
///
Future<void> getBackupInfo() async {
await Future.wait([
_getBackupAlbumsInfo(),
_updateServerInfo(),
]);
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await Future.wait([
_getBackupAlbumsInfo(),
_updateServerInfo(),
]);
await _updateBackupAssetCount();
await _updateBackupAssetCount();
}
}
///
@@ -276,6 +357,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Hive database
///
void _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Box<HiveBackupAlbums> backupAlbumInfoBox =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
backupAlbumInfoBox.put(
@@ -283,6 +365,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
HiveBackupAlbums(
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
lastSelectedBackupTime: state.selectedBackupAlbums
.map((e) => e.lastBackup ?? epoch)
.toList(),
lastExcludedBackupTime: state.excludedBackupAlbums
.map((e) => e.lastBackup ?? epoch)
.toList(),
),
);
}
@@ -290,7 +378,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
///
/// Invoke backup process
///
void startBackupProcess() async {
Future<void> startBackupProcess() async {
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
@@ -318,7 +407,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
_onAssetUploaded,
@@ -326,6 +415,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_onSetCurrentBackupAsset,
_onBackupError,
);
await _notifyBackgroundServiceCanRun();
} else {
PhotoManager.openSetting();
}
@@ -340,6 +430,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
void cancelBackup() {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
_notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
@@ -359,10 +452,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup =
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
(v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
excludedBackupAlbums: state.excludedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
.toSet(),
backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0,
);
_updatePersistentAlbumsSelection();
}
_updateServerInfo();
@@ -385,7 +489,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
}
void resumeBackup() {
Future<void> _resumeBackup() async {
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
@@ -404,13 +508,91 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
if (state.backupProgress == BackUpProgressEnum.inBackground) {
debugPrint("[resumeBackup] Background backup is running - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
await startBackupProcess();
}
return;
}
Future<void> resumeBackup() async {
if (Platform.isAndroid) {
// assumes the background service is currently running
// if true, waits until it has stopped to update the app state from HiveDB
// before actually resuming backup by calling the internal `_resumeBackup`
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
final bool hasLock = await _backgroundService.acquireLock();
if (!hasLock) {
return;
}
Box<HiveBackupAlbums> box =
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
HiveBackupAlbums? albums = box.get(backupInfoKey);
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (albums != null) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
albums.selectedAlbumIds,
albums.lastSelectedBackupTime,
);
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
albums.excludedAlbumsIds,
albums.lastExcludedBackupTime,
);
}
state = state.copyWith(
backupProgress: previous,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
}
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(
Set<AvailableAlbum> albums,
List<String> ids,
List<DateTime> times,
) {
Set<AvailableAlbum> result = {};
for (int i = 0; i < ids.length; i++) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
result.add(a.copyWith(lastBackup: times[i]));
} on StateError {
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
}
}
return result;
}
Future<void> _notifyBackgroundServiceCanRun() async {
const allowedStates = [
AppStateEnum.inactive,
AppStateEnum.paused,
AppStateEnum.detached,
];
if (Platform.isAndroid &&
allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
try {
if (Hive.isBoxOpen(hiveBackupInfoBox)) {
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
}
_backgroundService.releaseLock();
}
}
}
final backupProvider =
@@ -419,6 +601,7 @@ final backupProvider =
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
ref.watch(backgroundServiceProvider),
ref,
);
});

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -9,6 +10,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:openapi/api.dart';
@@ -39,8 +41,141 @@ class BackupService {
}
}
backupAsset(
Set<AssetEntity> assetList,
/// Returns all assets to backup from the backup info taking into account the
/// time of the last successfull backup per album
Future<List<AssetEntity>> getAssetsToBackup(
HiveBackupAlbums backupAlbumInfo,
) async {
final List<AssetEntity> candidates =
await _buildUploadCandidates(backupAlbumInfo);
final List<AssetEntity> toUpload = candidates.isEmpty
? []
: await _removeAlreadyUploadedAssets(candidates);
return toUpload;
}
Future<List<AssetEntity>> _buildUploadCandidates(
HiveBackupAlbums backupAlbums,
) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.selectedAlbumIds,
backupAlbums.lastSelectedBackupTime,
filter,
now,
);
if (selectedAlbums.every((e) => e == null)) {
return [];
}
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
if (allIdx != -1) {
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.excludedAlbumsIds,
backupAlbums.lastExcludedBackupTime,
filter,
now,
);
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums.slice(allIdx, allIdx + 1),
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
now,
);
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
backupAlbums.lastExcludedBackupTime,
now,
);
return toAdd.toSet().difference(toRemove.toSet()).toList();
} else {
return await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
backupAlbums.lastSelectedBackupTime,
now,
);
}
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<String> albumIds,
List<DateTime> lastBackups,
FilterOptionGroup filter,
DateTime now,
) async {
List<AssetPathEntity?> result = List.filled(albumIds.length, null);
for (int i = 0; i < albumIds.length; i++) {
try {
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: albumIds[i],
optionGroup: filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: lastBackups[i].subtract(const Duration(seconds: 2)),
max: now,
),
),
maxDateTimeToNow: false,
);
result[i] = album;
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> albums,
List<DateTime> lastBackup,
DateTime now,
) async {
List<AssetEntity> result = [];
for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i];
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
lastBackup[i] = now;
}
}
return result;
}
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
List<AssetEntity> candidates,
) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
if (candidates.length < 10) {
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
await Future.wait(
candidates.map(
(e) => _apiService.assetApi.checkDuplicateAsset(
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
),
),
);
return candidates
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
.toList();
} else {
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return candidates;
}
final Set<String> inDb = allAssetsInDatabase.toSet();
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
}
}
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgressCb,
@@ -50,6 +185,7 @@ class BackupService {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
bool anyErrors = false;
for (var entity in assetList) {
try {
@@ -134,9 +270,10 @@ class BackupService {
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
return;
return false;
} catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}");
anyErrors = true;
continue;
} finally {
if (Platform.isIOS) {
@@ -144,6 +281,7 @@ class BackupService {
}
}
}
return !anyErrors;
}
String _getAssetType(AssetType assetType) {

View File

@@ -6,14 +6,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:photo_manager/photo_manager.dart';
class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData;
final AssetPathEntity albumInfo;
final AvailableAlbum albumInfo;
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
: super(key: key);
@@ -24,6 +24,7 @@ class AlbumInfoCard extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
ColorFilter selectedFilter = ColorFilter.mode(
Theme.of(context).primaryColor.withAlpha(100),
@@ -39,11 +40,11 @@ class AlbumInfoCard extends HookConsumerWidget {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
label: Text(
"album_info_card_backup_album_included",
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
@@ -53,11 +54,11 @@ class AlbumInfoCard extends HookConsumerWidget {
return Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
label: const Text(
label: Text(
"album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
@@ -122,7 +123,7 @@ class AlbumInfoCard extends HookConsumerWidget {
return;
}
if (albumInfo.id == 'isAll') {
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
@@ -141,8 +142,10 @@ class AlbumInfoCard extends HookConsumerWidget {
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // if you need this
side: const BorderSide(
color: Color(0xFFC9C9C9),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 37, 35, 35)
: const Color(0xFFC9C9C9),
width: 1,
),
),
@@ -219,8 +222,9 @@ class AlbumInfoCard extends HookConsumerWidget {
),
IconButton(
onPressed: () {
AutoRouter.of(context)
.push(AlbumPreviewRoute(album: albumInfo));
AutoRouter.of(context).push(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},
icon: Icon(
Icons.image_outlined,

View File

@@ -35,7 +35,7 @@ class BackupInfoCard extends StatelessWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle,
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
style: const TextStyle(fontSize: 12),
),
),
trailing: Column(

View File

@@ -4,6 +4,7 @@ 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/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -16,6 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
useEffect(
() {
@@ -46,7 +48,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
: const EdgeInsets.all(0),
child: AlbumInfoCard(
imageData: thumbnailData,
albumInfo: availableAlbums[index].albumEntity,
albumInfo: availableAlbums[index],
),
);
}),
@@ -81,14 +83,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
label: Text(
album.name,
style: const TextStyle(
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: Theme.of(context).brightness == Brightness.dark
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Theme.of(context).primaryColor,
deleteIconColor: Colors.white,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
@@ -119,14 +123,15 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
label: Text(
album.name,
style: const TextStyle(
style: TextStyle(
fontSize: 10,
color: Colors.white,
color: isDarkTheme ? Colors.black : immichBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor: Colors.white,
deleteIconColor:
isDarkTheme ? Colors.black : immichBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
@@ -154,11 +159,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
physics: const ClampingScrollPhysics(),
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
child: const Text(
"backup_album_selection_page_selection_info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
),
// Selected Album Chips
@@ -178,9 +188,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(255, 235, 235, 235),
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0)
: const Color.fromARGB(255, 235, 235, 235),
width: 1,
),
),
@@ -190,12 +202,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
children: [
ListTile(
visualDensity: VisualDensity.compact,
title: Text(
title: const Text(
"backup_album_selection_page_total_assets",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.grey[700],
),
).tr(),
trailing: Text(
@@ -257,11 +268,10 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
content: SingleChildScrollView(
child: ListBody(
children: [
Text(
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
).tr(),
],

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -20,9 +22,12 @@ class BackupControllerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
AuthenticationState authenticationState = ref.watch(authenticationProvider);
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
backupState.selectedAlbumsBackupAssetsIds.length ==
0
backupState.selectedAlbumsBackupAssetsIds.length ==
0 ||
!hasExclusiveAccess
? false
: true;
@@ -82,7 +87,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
ListTile _buildBackupController() {
ListTile _buildAutoBackupController() {
var backUpOption = authenticationState.deviceInfo.isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
@@ -114,13 +119,7 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 220, 220, 220),
),
),
child: ElevatedButton(
onPressed: () {
if (isAutoBackup) {
ref
@@ -134,7 +133,10 @@ class BackupControllerPage extends HookConsumerWidget {
},
child: Text(
backupBtnText,
style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
)
@@ -144,6 +146,99 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
void _showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
),
backgroundColor: Colors.red,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
ListTile _buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final Color activeColor = Theme.of(context).primaryColor;
return ListTile(
isThreeLine: true,
leading: isBackgroundEnabled
? Icon(
Icons.cloud_sync_rounded,
color: activeColor,
)
: const Icon(Icons.cloud_sync_rounded),
title: Text(
isBackgroundEnabled
? "backup_controller_page_background_is_on"
: "backup_controller_page_background_is_off",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isBackgroundEnabled)
const Text("backup_controller_page_background_description").tr(),
if (isBackgroundEnabled)
SwitchListTile(
title:
const Text("backup_controller_page_background_wifi").tr(),
secondary: Icon(
Icons.wifi,
color: isWifiRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isWifiRequired,
onChanged: hasExclusiveAccess
? (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: _showErrorToUser,
)
: null,
),
if (isBackgroundEnabled)
SwitchListTile(
title: const Text("backup_controller_page_background_charging")
.tr(),
secondary: Icon(
Icons.charging_station,
color: isChargingRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isChargingRequired,
onChanged: hasExclusiveAccess
? (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: _showErrorToUser,
)
: null,
),
ElevatedButton(
onPressed: () =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: _showErrorToUser,
),
child: Text(
isBackgroundEnabled
? "backup_controller_page_background_turn_off"
: "backup_controller_page_background_turn_on",
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
],
),
);
}
Widget _buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
@@ -232,33 +327,27 @@ class BackupControllerPage extends HookConsumerWidget {
children: [
const Text(
"backup_controller_page_to_backup",
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
style: TextStyle(fontSize: 12),
).tr(),
_buildSelectedAlbumName(),
_buildExcludedAlbumName()
],
),
),
trailing: OutlinedButton(
style: OutlinedButton.styleFrom(
enableFeedback: true,
side: const BorderSide(
width: 1,
color: Color.fromARGB(255, 220, 220, 220),
trailing: ElevatedButton(
onPressed: hasExclusiveAccess
? () {
AutoRouter.of(context)
.push(const BackupAlbumSelectionRoute());
}
: null,
child: const Text(
"backup_controller_page_select",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
),
child: const Text(
"backup_controller_page_select",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
).tr(),
),
),
);
@@ -324,14 +413,14 @@ class BackupControllerPage extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: Colors.black12,
color: Theme.of(context).primaryColorLight,
width: 1,
),
children: [
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
decoration: const BoxDecoration(
// color: Colors.grey[100],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
@@ -355,9 +444,9 @@ class BackupControllerPage extends HookConsumerWidget {
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[200],
),
decoration: const BoxDecoration(
// color: Colors.grey[200],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
@@ -384,9 +473,9 @@ class BackupControllerPage extends HookConsumerWidget {
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
decoration: const BoxDecoration(
// color: Colors.grey[100],
),
children: [
TableCell(
child: Padding(
@@ -412,7 +501,10 @@ class BackupControllerPage extends HookConsumerWidget {
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
ref.watch(backupProvider.notifier).startBackupProcess();
if (ref.watch(backupProvider).backupProgress !=
BackUpProgressEnum.inBackground) {
ref.watch(backupProvider.notifier).startBackupProcess();
}
}
return Scaffold(
@@ -445,6 +537,27 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
).tr(),
),
hasExclusiveAccess
? const SizedBox.shrink()
: Card(
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
"Background backup is currently running, some actions are disabled",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
_buildFolderSelectionTile(),
BackupInfoCard(
title: "backup_controller_page_total".tr(),
@@ -463,7 +576,9 @@ class BackupControllerPage extends HookConsumerWidget {
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
),
const Divider(),
_buildBackupController(),
_buildAutoBackupController(),
if (Platform.isAndroid) const Divider(),
if (Platform.isAndroid) _buildBackgroundBackupController(),
const Divider(),
_buildStorageInformation(),
const Divider(),
@@ -479,7 +594,7 @@ class BackupControllerPage extends HookConsumerWidget {
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
// padding: const EdgeInsets.all(14),
),
onPressed: () {
ref.read(backupProvider.notifier).cancelBackup();
@@ -493,11 +608,6 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
),
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"backup_controller_page_start_backup",

View File

@@ -1,9 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class HomePageStateNotifier extends StateNotifier<HomePageState> {
HomePageStateNotifier()
final ShareService _shareService;
HomePageStateNotifier(this._shareService)
: super(
HomePageState(
isMultiSelectEnable: false,
@@ -64,9 +71,22 @@ class HomePageStateNotifier extends StateNotifier<HomePageState> {
state = state.copyWith(selectedItems: currentList);
}
void shareAssets(List<AssetResponseDto> assets, BuildContext context) {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService
.shareAssets(assets)
.then((_) => Navigator.of(buildContext).pop());
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(
((ref) => HomePageStateNotifier()),
((ref) => HomePageStateNotifier(ref.watch(shareServiceProvider))),
);

View File

@@ -1,12 +1,14 @@
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:immich_mobile/modules/home/ui/delete_diaglog.dart';
class ControlBottomAppBar extends StatelessWidget {
class ControlBottomAppBar extends ConsumerWidget {
const ControlBottomAppBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
bottom: 0,
left: 0,
@@ -15,17 +17,17 @@ class ControlBottomAppBar extends StatelessWidget {
height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
color: Colors.grey[300]?.withOpacity(0.98),
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.95),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ControlBoxButton(
iconData: Icons.delete_forever_rounded,
@@ -39,6 +41,20 @@ class ControlBottomAppBar extends StatelessWidget {
);
},
),
ControlBoxButton(
iconData: Icons.share,
label: "control_bottom_app_bar_share".tr(),
onPressed: () {
final homePageState = ref.watch(homePageStateProvider);
ref.watch(homePageStateProvider.notifier).shareAssets(
homePageState.selectedItems.toList(),
context,
);
ref
.watch(homePageStateProvider.notifier)
.disableMultiSelect();
},
),
],
),
)
@@ -67,7 +83,7 @@ class ControlBoxButton extends StatelessWidget {
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
onPressed: () {

View File

@@ -86,7 +86,6 @@ class DailyTitleText extends ConsumerWidget {
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const Spacer(),

View File

@@ -14,32 +14,22 @@ class DisableMultiSelectButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
top: 0,
top: 10,
left: 0,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 46),
child: Material(
elevation: 20,
borderRadius: BorderRadius.circular(35),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(35),
color: Colors.grey[100],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
),

View File

@@ -30,6 +30,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
floating: true,
pinned: false,
snap: false,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
),
@@ -57,7 +58,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(),
child: Material(
color: Colors.grey[200],
// color: Colors.grey[200],
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
@@ -77,13 +78,12 @@ class ImmichSliverAppBar extends ConsumerWidget {
);
},
),
title: Text(
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
actions: [
@@ -112,12 +112,13 @@ class ImmichSliverAppBar extends ConsumerWidget {
? const Icon(Icons.backup_rounded)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 2,
elevation: 3,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
Icons.cloud_off_rounded,
size: 8,
color: Colors.indigo,
),
child: const Icon(Icons.backup_rounded),
),

View File

@@ -22,7 +22,7 @@ class MonthlyTitleText extends StatelessWidget {
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
color: Theme.of(context).textTheme.headline1?.color,
),
),
),

View File

@@ -1,303 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'dart:math';
class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState authState = ref.watch(authenticationProvider);
ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
final appInfo = useState({});
var dummmy = Random().nextInt(1024);
_getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
_buildUserProfileImage() {
if (authState.profileImagePath.isEmpty) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent,
);
} else {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const ImmichLoadingIndicator();
}
return const SizedBox();
}
_pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxHeight: 1024,
maxWidth: 1024,
);
if (image != null) {
var success =
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) {
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
ref.read(uploadProfileImageProvider).profileImagePath,
);
}
}
}
useEffect(
() {
_getPackageInfo();
_buildUserProfileImage();
return null;
},
[],
);
return Drawer(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color.fromARGB(255, 216, 219, 238),
Color.fromARGB(255, 226, 230, 231)
],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
_buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
child: GestureDetector(
onTap: _pickUserProfileImage,
child: Material(
color: Colors.grey[50],
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
size: 14,
),
),
),
),
),
],
),
Text(
"${authState.firstName} ${authState.lastName}",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Text(
authState.userEmail,
style: TextStyle(color: Colors.grey[800], fontSize: 12),
)
],
),
),
ListTile(
tileColor: Colors.grey[100],
leading: const Icon(
Icons.logout_rounded,
color: Colors.black54,
),
title: const Text(
"profile_drawer_sign_out",
style: TextStyle(
color: Colors.black54,
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
onTap: () async {
bool res =
await ref.watch(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
// AutoRouter.of(context).popUntilRoot();
AutoRouter.of(context).replace(const LoginRoute());
}
},
)
],
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
color: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(101, 201, 201, 201),
width: 1,
),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage
: "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600,
),
),
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"App Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Server Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
)
],
),
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
_buildSignoutButton() {
return ListTile(
horizontalTitleGap: 0,
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.logout_rounded,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_sign_out",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () async {
bool res = await ref.watch(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
}
},
);
}
_buildSettingButton() {
return ListTile(
horizontalTitleGap: 0,
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.settings_rounded,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_settings",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const SettingsRoute());
},
);
}
return Drawer(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
const ProfileDrawerHeader(),
_buildSettingButton(),
_buildSignoutButton(),
],
),
const ServerInfoBox()
],
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class ProfileDrawerHeader extends HookConsumerWidget {
const ProfileDrawerHeader({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
AuthenticationState authState = ref.watch(authenticationProvider);
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
var dummmy = Random().nextInt(1024);
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
_buildUserProfileImage() {
if (authState.profileImagePath.isEmpty) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent,
);
} else {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
return CircleAvatar(
radius: 35,
backgroundImage: NetworkImage(
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const ImmichLoadingIndicator();
}
return const SizedBox();
}
_pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxHeight: 1024,
maxWidth: 1024,
);
if (image != null) {
var success =
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) {
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
ref.read(uploadProfileImageProvider).profileImagePath,
);
}
}
}
useEffect(
() {
_buildUserProfileImage();
return null;
},
[],
);
return DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDarkMode
? [
const Color.fromARGB(255, 22, 25, 48),
const Color.fromARGB(255, 13, 13, 13),
const Color.fromARGB(255, 0, 0, 0),
]
: [
const Color.fromARGB(255, 216, 219, 238),
const Color.fromARGB(255, 242, 242, 242),
Colors.white,
],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
_buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
child: GestureDetector(
onTap: _pickUserProfileImage,
child: Material(
color: Colors.grey[100],
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
size: 14,
),
),
),
),
),
],
),
Text(
"${authState.firstName} ${authState.lastName}",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Text(
authState.userEmail,
style: Theme.of(context).textTheme.labelMedium,
)
],
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
class ServerInfoBox extends HookConsumerWidget {
const ServerInfoBox({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
final appInfo = useState({});
_getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
useEffect(
() {
_getPackageInfo();
return null;
},
[],
);
return Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
color: Theme.of(context).scaffoldBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(101, 201, 201, 201),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage
: "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600,
),
),
),
const Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"App Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Server Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class ThumbnailImage extends HookConsumerWidget {
@@ -23,8 +24,7 @@ class ThumbnailImage extends HookConsumerWidget {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
var thumbnailRequestUrl = getThumbnailUrl(asset);
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable;
@@ -65,7 +65,6 @@ class ThumbnailImage extends HookConsumerWidget {
AutoRouter.of(context).push(
GalleryViewerRoute(
assetList: assetList,
thumbnailRequestUrl: thumbnailRequestUrl,
asset: asset,
),
);

View File

@@ -9,7 +9,7 @@ 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/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
@@ -76,6 +76,7 @@ class HomePage extends HookConsumerWidget {
imageGridGroup.add(
DailyTitleText(
key: Key('${dateGroup.toString()}title'),
isoDate: dateGroup,
assetGroup: immichAssetList,
),
@@ -116,9 +117,9 @@ class HomePage extends HookConsumerWidget {
],
),
Padding(
padding: const EdgeInsets.only(top: 50.0),
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(

View File

@@ -26,7 +26,7 @@ class ThumbnailWithInfo extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: MediaQuery.of(context).size.width / 2,
width: MediaQuery.of(context).size.width / 3,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
@@ -58,7 +58,7 @@ class ThumbnailWithInfo extends StatelessWidget {
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
fontSize: 14,
),
),
),

View File

@@ -29,6 +29,8 @@ class SearchPage extends HookConsumerWidget {
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
ref.watch(getCuratedObjectProvider);
double imageSize = MediaQuery.of(context).size.width / 3;
useEffect(
() {
searchFocusNode = FocusNode();
@@ -46,15 +48,15 @@ class SearchPage extends HookConsumerWidget {
_buildPlaces() {
return curatedLocation.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: ImmichLoadingIndicator()),
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'),
data: (curatedLocations) {
return curatedLocations.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 2,
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -76,7 +78,7 @@ class SearchPage extends HookConsumerWidget {
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 2,
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -105,7 +107,7 @@ class SearchPage extends HookConsumerWidget {
data: (objects) {
return objects.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 2,
height: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -113,7 +115,7 @@ class SearchPage extends HookConsumerWidget {
itemBuilder: ((context, index) {
var curatedObjectInfo = objects[index];
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${curatedObjectInfo.deviceAssetId}&did=${curatedObjectInfo.deviceId}&isThumb=true';
'${box.get(serverEndpointKey)}/asset/thumbnail/${curatedObjectInfo.id}';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
@@ -131,7 +133,8 @@ class SearchPage extends HookConsumerWidget {
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 2,
// height: imageSize,
width: imageSize,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -163,12 +166,13 @@ class SearchPage extends HookConsumerWidget {
child: Stack(
children: [
ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: const Text(
"search_page_places",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
_buildPlaces(),
@@ -176,7 +180,7 @@ class SearchPage extends HookConsumerWidget {
padding: const EdgeInsets.all(16.0),
child: const Text(
"search_page_things",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
_buildThings()

View File

@@ -172,7 +172,7 @@ class SearchResultPage extends HookConsumerWidget {
});
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(

View File

@@ -0,0 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((ref) => AppSettingsService());

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
enum AppSettingsEnum {
threeStageLoading, // true, false,
themeMode, // "light","dark","system"
}
class AppSettingsService {
late final Box hiveBox;
AppSettingsService() {
hiveBox = Hive.box(userSettingInfoBox);
}
T getSetting<T>(AppSettingsEnum settingType) {
var settingKey = _settingHiveBoxKeyLookup(settingType);
if (!hiveBox.containsKey(settingKey)) {
T defaultSetting = _setDefaultSetting(settingType);
return defaultSetting;
}
var result = hiveBox.get(settingKey);
if (result is T) {
return result;
} else {
debugPrint("Incorrect setting type");
throw TypeError();
}
}
setSetting<T>(AppSettingsEnum settingType, T value) {
var settingKey = _settingHiveBoxKeyLookup(settingType);
if (hiveBox.containsKey(settingKey)) {
var result = hiveBox.get(settingKey);
if (result is! T) {
debugPrint("Incorrect setting type");
throw TypeError();
}
hiveBox.put(settingKey, value);
} else {
hiveBox.put(settingKey, value);
}
}
_setDefaultSetting(AppSettingsEnum settingType) {
var settingKey = _settingHiveBoxKeyLookup(settingType);
// Default value of threeStageLoading is false
if (settingType == AppSettingsEnum.threeStageLoading) {
hiveBox.put(settingKey, false);
return false;
}
// Default value of themeMode is "light"
if (settingType == AppSettingsEnum.themeMode) {
hiveBox.put(settingKey, "system");
return "system";
}
}
String _settingHiveBoxKeyLookup(AppSettingsEnum settingType) {
switch (settingType) {
case AppSettingsEnum.threeStageLoading:
return 'threeStageLoading';
case AppSettingsEnum.themeMode:
return 'themeMode';
}
}
}

View File

@@ -0,0 +1,31 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/three_stage_loading.dart';
class ImageViewerQualitySetting extends StatelessWidget {
const ImageViewerQualitySetting({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
'theme_setting_image_viewer_quality_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'theme_setting_image_viewer_quality_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: const [
ThreeStageLoading(),
],
);
}
}

View File

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

View File

@@ -0,0 +1,106 @@
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';
import 'package:immich_mobile/utils/immich_app_theme.dart';
class ThemeSetting extends HookConsumerWidget {
const ThemeSetting({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentTheme = useState<ThemeMode>(ThemeMode.system);
useEffect(
() {
currentTheme.value = ref.read(immichThemeProvider);
return null;
},
[],
);
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
'theme_setting_theme_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'theme_setting_theme_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: [
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
'theme_setting_system_theme_switch',
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
),
).tr(),
value: currentTheme.value == ThemeMode.system,
onChanged: (bool isSystem) {
var currentSystemBrightness =
MediaQuery.of(context).platformBrightness;
if (isSystem) {
currentTheme.value = ThemeMode.system;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.system;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "system");
} else {
if (currentSystemBrightness == Brightness.light) {
currentTheme.value = ThemeMode.light;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "light");
} else if (currentSystemBrightness == Brightness.dark) {
currentTheme.value = ThemeMode.dark;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "dark");
}
}
},
),
if (currentTheme.value != ThemeMode.system)
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
'theme_setting_dark_mode_switch',
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
),
).tr(),
value: ref.watch(immichThemeProvider) == ThemeMode.dark,
onChanged: (bool isDark) {
if (isDark) {
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "dark");
} else {
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "light");
}
},
),
],
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
class SettingsPage extends HookConsumerWidget {
const SettingsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
iconSize: 20,
splashRadius: 24,
onPressed: () {
Navigator.pop(context);
},
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
automaticallyImplyLeading: false,
centerTitle: false,
title: const Text(
'setting_pages_app_bar_settings',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
body: ListView(
children: [
...ListTile.divideTiles(
context: context,
tiles: [
const ImageViewerQualitySetting(),
const ThemeSetting(),
],
).toList(),
],
),
);
}
}

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
@@ -77,6 +78,7 @@ part 'router.gr.dart';
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -46,10 +46,7 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
routeData: routeData,
child: GalleryViewerPage(
key: args.key,
assetList: args.assetList,
asset: args.asset,
thumbnailRequestUrl: args.thumbnailRequestUrl));
key: args.key, assetList: args.assetList, asset: args.asset));
},
ImageViewerRoute.name: (routeData) {
final args = routeData.argsAs<ImageViewerRouteArgs>();
@@ -57,13 +54,14 @@ class _$AppRouter extends RootStackRouter {
routeData: routeData,
child: ImageViewerPage(
key: args.key,
imageUrl: args.imageUrl,
heroTag: args.heroTag,
thumbnailUrl: args.thumbnailUrl,
asset: args.asset,
authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener));
isZoomedListener: args.isZoomedListener,
onLoadingCompleted: args.onLoadingCompleted,
onLoadingStart: args.onLoadingStart,
threeStageLoading: args.threeStageLoading));
},
VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -139,6 +137,10 @@ class _$AppRouter extends RootStackRouter {
opaque: true,
barrierDismissible: false);
},
SettingsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const SettingsPage());
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
@@ -213,7 +215,9 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(AlbumPreviewRoute.name,
path: '/album-preview-page', guards: [authGuard]),
RouteConfig(FailedBackupStatusRoute.name,
path: '/failed-backup-status-page', guards: [authGuard])
path: '/failed-backup-status-page', guards: [authGuard]),
RouteConfig(SettingsRoute.name,
path: '/settings-page', guards: [authGuard])
];
}
@@ -258,25 +262,18 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute(
{Key? key,
required List<AssetResponseDto> assetList,
required AssetResponseDto asset,
required String thumbnailRequestUrl})
required AssetResponseDto asset})
: super(GalleryViewerRoute.name,
path: '/gallery-viewer-page',
args: GalleryViewerRouteArgs(
key: key,
assetList: assetList,
asset: asset,
thumbnailRequestUrl: thumbnailRequestUrl));
key: key, assetList: assetList, asset: asset));
static const String name = 'GalleryViewerRoute';
}
class GalleryViewerRouteArgs {
const GalleryViewerRouteArgs(
{this.key,
required this.assetList,
required this.asset,
required this.thumbnailRequestUrl});
{this.key, required this.assetList, required this.asset});
final Key? key;
@@ -284,11 +281,9 @@ class GalleryViewerRouteArgs {
final AssetResponseDto asset;
final String thumbnailRequestUrl;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset, thumbnailRequestUrl: $thumbnailRequestUrl}';
return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset}';
}
}
@@ -297,24 +292,26 @@ class GalleryViewerRouteArgs {
class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
ImageViewerRoute(
{Key? key,
required String imageUrl,
required String heroTag,
required String thumbnailUrl,
required AssetResponseDto asset,
required String authToken,
required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener})
required ValueNotifier<bool> isZoomedListener,
required void Function() onLoadingCompleted,
required void Function() onLoadingStart,
required bool threeStageLoading})
: super(ImageViewerRoute.name,
path: '/image-viewer-page',
args: ImageViewerRouteArgs(
key: key,
imageUrl: imageUrl,
heroTag: heroTag,
thumbnailUrl: thumbnailUrl,
asset: asset,
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener));
isZoomedListener: isZoomedListener,
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
threeStageLoading: threeStageLoading));
static const String name = 'ImageViewerRoute';
}
@@ -322,22 +319,19 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
class ImageViewerRouteArgs {
const ImageViewerRouteArgs(
{this.key,
required this.imageUrl,
required this.heroTag,
required this.thumbnailUrl,
required this.asset,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener});
required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading});
final Key? key;
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final AssetResponseDto asset;
final String authToken;
@@ -346,9 +340,15 @@ class ImageViewerRouteArgs {
final ValueNotifier<bool> isZoomedListener;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading;
@override
String toString() {
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener}';
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}';
}
}
@@ -552,6 +552,14 @@ class FailedBackupStatusRoute extends PageRouteInfo<void> {
static const String name = 'FailedBackupStatusRoute';
}
/// generated route for
/// [SettingsPage]
class SettingsRoute extends PageRouteInfo<void> {
const SettingsRoute() : super(SettingsRoute.name, path: '/settings-page');
static const String name = 'SettingsRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:openapi/api.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path/path.dart' as p;
import 'api.service.dart';
final shareServiceProvider =
Provider((ref) => ShareService(ref.watch(apiServiceProvider)));
class ShareService {
final ApiService _apiService;
ShareService(this._apiService);
Future<void> shareAsset(AssetResponseDto asset) async {
await shareAssets([asset]);
}
Future<void> shareAssets(List<AssetResponseDto> assets) async {
final downloadedFilePaths = assets.map((asset) async {
final res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.deviceAssetId,
asset.deviceId,
isThumb: false,
isWeb: false,
);
final fileName = p.basename(asset.originalPath);
final tempDir = await getTemporaryDirectory();
final tempFile = await File('${tempDir.path}/$fileName').create();
tempFile.writeAsBytesSync(res.bodyBytes);
return tempFile.path;
});
Share.shareFiles(
await Future.wait(downloadedFilePaths),
sharePositionOrigin: Rect.zero,
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class ShareDialog extends StatelessWidget {
const ShareDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Container(
margin: const EdgeInsets.only(top: 12),
child: const Text('share_dialog_preparing')
.tr(),
)
],
),
);
}
}

View File

@@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -49,7 +48,6 @@ class SplashScreenPage extends HookConsumerWidget {
);
return Scaffold(
backgroundColor: immichBackgroundColor,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -36,8 +35,6 @@ class TabControllerPage extends ConsumerWidget {
bottomNavigationBar: isMultiSelectEnable
? null
: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: immichBackgroundColor,
selectedLabelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
@@ -53,21 +50,23 @@ class TabControllerPage extends ConsumerWidget {
items: [
BottomNavigationBarItem(
label: 'tab_controller_nav_photos'.tr(),
icon: const Icon(Icons.photo),
icon: const Icon(Icons.photo_outlined),
activeIcon: const Icon(Icons.photo),
),
BottomNavigationBarItem(
label: 'tab_controller_nav_search'.tr(),
icon: const Icon(Icons.search),
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search),
),
BottomNavigationBarItem(
label: 'tab_controller_nav_sharing'.tr(),
icon: const Icon(Icons.group_outlined),
activeIcon: const Icon(Icons.group),
),
BottomNavigationBarItem(
label: 'tab_controller_nav_library'.tr(),
icon: const Icon(
Icons.photo_album_outlined,
),
icon: const Icon(Icons.photo_album_outlined),
activeIcon: const Icon(Icons.photo_album_rounded),
)
],
),

View File

@@ -0,0 +1,16 @@
import 'package:hive/hive.dart';
import 'package:openapi/api.dart';
import '../constants/hive_box.dart';
String getThumbnailUrl(final AssetResponseDto asset,
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}?format=${type.value}';
}
String getImageUrl(final AssetResponseDto asset) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
final immichThemeProvider = StateProvider<ThemeMode>((ref) {
var themeMode = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.themeMode);
debugPrint("Current themeMode $themeMode");
if (themeMode == "light") {
return ThemeMode.light;
} else if (themeMode == "dark") {
return ThemeMode.dark;
} else {
return ThemeMode.system;
}
});
ThemeData immichDarkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
primarySwatch: Colors.indigo,
primaryColor: immichDarkThemePrimaryColor,
scaffoldBackgroundColor: immichDarkBackgroundColor,
hintColor: Colors.grey[600],
fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
),
appBarTheme: AppBarTheme(
titleTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: immichDarkThemePrimaryColor,
),
backgroundColor: const Color.fromARGB(255, 32, 33, 35),
foregroundColor: immichDarkThemePrimaryColor,
elevation: 1,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: const Color.fromARGB(255, 35, 36, 37),
selectedItemColor: immichDarkThemePrimaryColor,
),
drawerTheme: DrawerThemeData(
backgroundColor: immichDarkBackgroundColor,
scrimColor: Colors.white.withOpacity(0.1),
),
textTheme: TextTheme(
headline1: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 255, 255, 255),
),
headline2: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 148, 151, 155),
),
headline3: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: immichDarkThemePrimaryColor,
),
),
cardColor: Colors.grey[900],
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
onPrimary: Colors.black87,
primary: immichDarkThemePrimaryColor,
),
),
);
ThemeData immichLightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.indigo,
hintColor: Colors.indigo,
fontFamily: 'WorkSans',
scaffoldBackgroundColor: immichBackgroundColor,
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
),
appBarTheme: AppBarTheme(
titleTextStyle: const TextStyle(
fontFamily: 'WorkSans',
color: Colors.indigo,
),
backgroundColor: immichBackgroundColor,
foregroundColor: Colors.indigo,
elevation: 1,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
backgroundColor: immichBackgroundColor,
selectedItemColor: Colors.indigo,
),
drawerTheme: DrawerThemeData(
backgroundColor: immichBackgroundColor,
),
textTheme: const TextTheme(
headline1: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.indigo,
),
headline2: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
headline3: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.indigo,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: Colors.indigo,
onPrimary: Colors.white,
),
),
);

View File

@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assetCount** | **int** | |
**id** | **String** | |
**ownerId** | **String** | |
**albumName** | **String** | |

View File

@@ -13,6 +13,7 @@ part of openapi.api;
class AlbumResponseDto {
/// Returns a new [AlbumResponseDto] instance.
AlbumResponseDto({
required this.assetCount,
required this.id,
required this.ownerId,
required this.albumName,
@@ -23,6 +24,8 @@ class AlbumResponseDto {
this.assets = const [],
});
int assetCount;
String id;
String ownerId;
@@ -41,6 +44,7 @@ class AlbumResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
other.assetCount == assetCount &&
other.id == id &&
other.ownerId == ownerId &&
other.albumName == albumName &&
@@ -53,6 +57,7 @@ class AlbumResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetCount.hashCode) +
(id.hashCode) +
(ownerId.hashCode) +
(albumName.hashCode) +
@@ -63,10 +68,11 @@ class AlbumResponseDto {
(assets.hashCode);
@override
String toString() => 'AlbumResponseDto[id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'assetCount'] = assetCount;
_json[r'id'] = id;
_json[r'ownerId'] = ownerId;
_json[r'albumName'] = albumName;
@@ -101,6 +107,7 @@ class AlbumResponseDto {
}());
return AlbumResponseDto(
assetCount: mapValueOfType<int>(json, r'assetCount')!,
id: mapValueOfType<String>(json, r'id')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
albumName: mapValueOfType<String>(json, r'albumName')!,
@@ -158,6 +165,7 @@ class AlbumResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetCount',
'id',
'ownerId',
'albumName',

View File

@@ -76,72 +76,69 @@ class AssetResponseDto {
SmartInfoResponseDto? smartInfo;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode);
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode);
@override
String toString() =>
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
if (resizePath != null) {
_json[r'resizePath'] = resizePath;
} else {
_json[r'resizePath'] = null;
}
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
if (mimeType != null) {
_json[r'mimeType'] = mimeType;
} else {
_json[r'mimeType'] = null;
}
_json[r'duration'] = duration;
_json[r'duration'] = duration;
if (webpPath != null) {
_json[r'webpPath'] = webpPath;
} else {
@@ -177,10 +174,8 @@ class AssetResponseDto {
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key),
'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null,
'Required key "AssetResponseDto[$key]" has a null value in JSON.');
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
@@ -207,10 +202,7 @@ class AssetResponseDto {
return null;
}
static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -238,18 +230,12 @@ class AssetResponseDto {
}
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
@@ -276,3 +262,4 @@ class AssetResponseDto {
'encodedVideoPath',
};
}

View File

@@ -328,6 +328,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.0"
flutter_displaymode:
dependency: "direct main"
description:
name: flutter_displaymode
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0"
flutter_hooks:
dependency: "direct main"
description:
@@ -868,6 +875,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.27.3"
share_plus:
dependency: "direct main"
description:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.10"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences:
dependency: transitive
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.20.0+30
version: 1.24.0+34
environment:
sdk: ">=2.17.0 <3.0.0"
@@ -41,6 +41,8 @@ dependencies:
http: 0.13.4
cancellation_token_http: ^1.1.0
easy_localization: ^3.0.1
share_plus: ^4.0.10
flutter_displaymode: ^0.4.0
path: ^1.8.1
path_provider: ^2.0.11

View File

@@ -134,21 +134,14 @@ export class AlbumRepository implements IAlbumRepository {
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.where('album.ownerId = :ownerId', { ownerId: userId });
// .orWhere((qb) => {
// const subQuery = qb
// .subQuery()
// .select('userAlbum.albumId')
// .from(UserAlbumEntity, 'userAlbum')
// .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
// .getQuery();
// return `album.id IN ${subQuery}`;
// });
}
// Get information of assets in albums
query = query
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
const albums = await query.getMany();
albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());

View File

@@ -4,6 +4,7 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
describe('Album service', () => {
let sut: AlbumService;
@@ -12,7 +13,7 @@ describe('Album service', () => {
id: '1111',
email: 'auth@test.com',
});
const albumId = '0001';
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';
const sharedAlbumSharedAlsoWithId = '3333';
const ownedAlbumSharedWithId = '4444';
@@ -148,7 +149,7 @@ describe('Album service', () => {
it('gets an owned album', async () => {
const ownerId = authUser.id;
const albumId = '0001';
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
@@ -157,11 +158,12 @@ describe('Album service', () => {
albumName: 'name',
albumThumbnailAssetId: null,
createdAt: 'date',
id: '0001',
id: 'f19ab956-4761-41ea-a5d6-bae948308d58',
ownerId,
shared: false,
assets: [],
sharedUsers: [],
assetCount: 0,
};
await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
});
@@ -270,6 +272,7 @@ describe('Album service', () => {
authUser,
{
albumName: updatedAlbumName,
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
},
albumId,
);
@@ -279,7 +282,7 @@ describe('Album service', () => {
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
albumName: updatedAlbumName,
thumbnailAssetId: updatedAlbumThumbnailAssetId,
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
});
});
@@ -357,45 +360,45 @@ describe('Album service', () => {
).rejects.toBeInstanceOf(ForbiddenException);
});
it('removes assets from owned album', async () => {
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// it('removes assets from owned album', async () => {
// const albumEntity = _getOwnedAlbum();
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(
sut.removeAssetsFromAlbum(
authUser,
{
assetIds: ['1'],
},
albumEntity.id,
),
).resolves.toBeUndefined();
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
assetIds: ['1'],
});
});
// await expect(
// sut.removeAssetsFromAlbum(
// authUser,
// {
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
// },
// albumEntity.id,
// ),
// ).resolves.toBeUndefined();
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
// });
// });
it('removes assets from shared album (shared with auth user)', async () => {
const albumEntity = _getOwnedSharedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// it('removes assets from shared album (shared with auth user)', async () => {
// const albumEntity = _getOwnedSharedAlbum();
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(
sut.removeAssetsFromAlbum(
authUser,
{
assetIds: ['1'],
},
albumEntity.id,
),
).resolves.toBeUndefined();
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
assetIds: ['1'],
});
});
// await expect(
// sut.removeAssetsFromAlbum(
// authUser,
// {
// assetIds: ['1'],
// },
// albumEntity.id,
// ),
// ).resolves.toBeUndefined();
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// assetIds: ['1'],
// });
// });
it('prevents removing assets from a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
@@ -414,4 +417,33 @@ describe('Album service', () => {
),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('counts assets correctly', async () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = authUser.id;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = 'date';
albumEntity.sharedUsers = [];
albumEntity.assets = [
{
id: '1',
albumId: '2',
assetId: '3',
//@ts-expect-error Partial stub
albumInfo: {},
//@ts-expect-error Partial stub
assetInfo: {},
},
];
albumEntity.albumThumbnailAssetId = null;
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve([albumEntity]));
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1);
});
});

View File

@@ -7,7 +7,7 @@ import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
@Injectable()
@@ -49,7 +49,8 @@ export class AlbumService {
*/
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
return albums.map((album) => mapAlbum(album));
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
}
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
@@ -80,8 +81,6 @@ export class AlbumService {
await this._albumRepository.removeUser(album, sharedUserId);
}
// async removeUsersFromAlbum() {}
async removeAssetsFromAlbum(
authUser: AuthUserDto,
removeAssetsDto: RemoveAssetsDto,
@@ -89,7 +88,6 @@ export class AlbumService {
): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const updateAlbum = await this._albumRepository.removeAssets(album, removeAssetsDto);
return mapAlbum(updateAlbum);
}

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