Compare commits
20 Commits
v1.6.0_10-
...
v1.9.0_13-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373b6918f8 | ||
|
|
f1396761b0 | ||
|
|
335bb0707c | ||
|
|
7a51e0dd4d | ||
|
|
5b42899dde | ||
|
|
229357df2b | ||
|
|
8d5626620b | ||
|
|
2c4243b3d0 | ||
|
|
38e0178c81 | ||
|
|
c5c7a134dd | ||
|
|
da9eb61532 | ||
|
|
c1ccf026f0 | ||
|
|
4309104925 | ||
|
|
a3b84b3ca7 | ||
|
|
f6630163b1 | ||
|
|
aebeb37fb0 | ||
|
|
b74ad69288 | ||
|
|
b6579cd38e | ||
|
|
46a2032b9a | ||
|
|
0eb548f115 |
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: alextran1502
|
github: alextran1502
|
||||||
|
custom: https://www.buymeacoffee.com/altran1502?new=1
|
||||||
16
.github/workflows/build_push_docker_latest.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: "main" # branch
|
ref: "main" # branch
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1.6.0
|
uses: docker/setup-buildx-action@v2.0.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push Immich
|
- name: Build and push Immich
|
||||||
uses: docker/build-push-action@v2.10.0
|
uses: docker/build-push-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
@@ -44,17 +44,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: "main" # branch
|
ref: "main" # branch
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1.6.0
|
uses: docker/setup-buildx-action@v2.0.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Microservices
|
- name: Build and Push Microservices
|
||||||
uses: docker/build-push-action@v2.10.0
|
uses: docker/build-push-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
context: ./microservices
|
context: ./microservices
|
||||||
file: ./microservices/Dockerfile
|
file: ./microservices/Dockerfile
|
||||||
|
|||||||
38
.github/workflows/build_push_server_release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build Server - Release
|
name: Build and push Docker image - Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -14,12 +14,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: "main"
|
ref: "main"
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v3
|
|
||||||
with:
|
|
||||||
images: altran1502/immich-server
|
|
||||||
|
|
||||||
- name: 'Get Previous tag'
|
- name: 'Get Previous tag'
|
||||||
id: previoustag
|
id: previoustag
|
||||||
@@ -28,27 +22,27 @@ jobs:
|
|||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1.6.0
|
uses: docker/setup-buildx-action@v2.0.0
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-server release
|
- name: Build and push immich-server release
|
||||||
uses: docker/build-push-action@v2.10.0
|
uses: docker/build-push-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.previoustag.outputs.tag }}
|
tags: |
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
|
||||||
|
|
||||||
build_and_push_microservice_release:
|
build_and_push_microservice_release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -59,12 +53,6 @@ jobs:
|
|||||||
ref: "main"
|
ref: "main"
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v3
|
|
||||||
with:
|
|
||||||
images: altran1502/immich-microservices
|
|
||||||
|
|
||||||
- name: 'Get Previous tag'
|
- name: 'Get Previous tag'
|
||||||
id: previoustag
|
id: previoustag
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
@@ -72,24 +60,24 @@ jobs:
|
|||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1.6.0
|
uses: docker/setup-buildx-action@v2.0.0
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-microservices release
|
- name: Build and push immich-microservices release
|
||||||
uses: docker/build-push-action@v2.10.0
|
uses: docker/build-push-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
context: ./microservices
|
context: ./microservices
|
||||||
file: ./microservices/Dockerfile
|
file: ./microservices/Dockerfile
|
||||||
platforms: linux/arm/v7,linux/amd64
|
platforms: linux/arm/v7,linux/amd64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.previoustag.outputs.tag }}
|
tags: |
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||||
|
|
||||||
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
|
||||||
|
|
||||||
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||||
|
|
||||||
|
|||||||
133
README.md
@@ -32,10 +32,12 @@ Loading ~4000 images/videos
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<img src="design/nsc1.png" width="150" title="Login With Custom URL">
|
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
|
||||||
<img src="design/nsc2.png" width="150" title="Backup Setting Info">
|
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
|
||||||
<img src="design/nsc3.png" width="150" title="Multiple select">
|
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
|
||||||
<img src="design/nsc4.jpeg" width="150" title="Curated Search 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">
|
<img src="design/nsc6.png" width="150" title="EXIF Info">
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
@@ -48,33 +50,48 @@ This project is under heavy development, there will be continous functions, feat
|
|||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
- Upload and view assets(videos/images).
|
- Upload and view assets (videos/images).
|
||||||
|
- Auto Backup.
|
||||||
|
- Download asset to local device.
|
||||||
- Multi-user supported.
|
- Multi-user supported.
|
||||||
- Quick navigation with drag scroll bar.
|
- Quick navigation with drag scroll bar.
|
||||||
- Auto Backup.
|
|
||||||
- Support HEIC/HEIF Backup.
|
- Support HEIC/HEIF Backup.
|
||||||
- Extract and display EXIF info.
|
- Extract and display EXIF info.
|
||||||
- Real-time render from multi-device upload event.
|
- Real-time render from multi-device upload event.
|
||||||
- Image Tagging/Classification based on ImageNet dataset
|
- Image Tagging/Classification based on ImageNet dataset
|
||||||
- Object detection based on COCO SSD.
|
- Object detection based on COCO SSD.
|
||||||
- Search assets based on tags and exif data (lens, make, model, orientation)
|
- Search assets based on tags and exif data (lens, make, model, orientation)
|
||||||
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
|
|
||||||
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
||||||
- Show asset's location information on map (OpenStreetMap).
|
- Show asset's location information on map (OpenStreetMap).
|
||||||
- Show curated places on the search page
|
- Show curated places on the search page
|
||||||
- Show curated objects on the search page
|
- Show curated objects on the search page
|
||||||
|
- Shared album with users on the same server
|
||||||
|
- Selective backup - albums can be included and excluded during the backup process.
|
||||||
|
|
||||||
# Development
|
|
||||||
|
|
||||||
You can use docker compose for development, there are several services that compose Immich
|
# System Requirement
|
||||||
|
|
||||||
1. NestJs
|
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc).
|
||||||
2. PostgreSQL
|
|
||||||
3. Redis
|
|
||||||
4. Nginx
|
|
||||||
5. TensorFlow
|
|
||||||
|
|
||||||
## Populate .env file
|
I haven't tested with `Docker for Windows` as well as `WSL` on Windows
|
||||||
|
|
||||||
|
*Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Dockec image on arm64v7 yet.*
|
||||||
|
|
||||||
|
**RAM**: At least 2GB, preffered 4GB.
|
||||||
|
|
||||||
|
**Core**: At least 2 cores, preffered 4 cores.
|
||||||
|
|
||||||
|
# Development and Testing out the application
|
||||||
|
|
||||||
|
You can use docker compose for development and testing out the application, there are several services that compose Immich:
|
||||||
|
|
||||||
|
1. **NestJs** - Backend of the application
|
||||||
|
2. **PostgreSQL** - Main database of the application
|
||||||
|
3. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
|
||||||
|
4. **Nginx** - Load balancing and optimized file uploading.
|
||||||
|
5. **TensorFlow** - Object Detection and Image Classification.
|
||||||
|
|
||||||
|
## Step 1: Populate .env file
|
||||||
|
|
||||||
Navigate to `docker` directory and run
|
Navigate to `docker` directory and run
|
||||||
|
|
||||||
@@ -88,15 +105,44 @@ Notice that if set `ENABLE_MAPBOX` to `true`, you will have to provide `MAPBOX_K
|
|||||||
|
|
||||||
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
|
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
|
||||||
|
|
||||||
|
**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=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Start the server
|
||||||
|
|
||||||
To start, run
|
To start, run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f ./docker/docker-compose.yml up --build -V
|
docker-compose -f ./docker/docker-compose.yml up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_server` container to handle high I/O load when using fast scrolling.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f ./docker/docker-compose.yml up --scale immich_server=5
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
The server will be running at `http://your-ip:2283` through `Nginx`
|
The server will be running at `http://your-ip:2283` through `Nginx`
|
||||||
|
|
||||||
## Register User
|
## Step 3: Register User
|
||||||
|
|
||||||
Use the command below on your terminal to create user as we don't have user interface for this function yet.
|
Use the command below on your terminal to create user as we don't have user interface for this function yet.
|
||||||
|
|
||||||
@@ -109,25 +155,44 @@ curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run mobile app
|
## Step 4: Run mobile app
|
||||||
|
|
||||||
### Android
|
The app is distributed on several platforms below.
|
||||||
|
|
||||||
Download `apk` in release tab and run on your phone. You can follow this guide on how to do that
|
## F-Droid
|
||||||
|
You can get the app on F-droid by clicking the image below.
|
||||||
|
|
||||||
- [Run APK on Android](https://www.lifewire.com/install-apk-on-android-4177185)
|
[<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)
|
||||||
|
|
||||||
### iOS
|
|
||||||
|
|
||||||
You can download the app from Apple AppStore [here](https://apps.apple.com/us/app/immich/id1613945652):
|
## 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">
|
<p align="left">
|
||||||
<img src="design/ios-qr-code.png" width="250" title="Apple App Store">
|
<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/>
|
<p/>
|
||||||
|
|
||||||
# Support
|
# 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 [**Github Sponsore**](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 [**Github Sponsore**](https://github.com/sponsors/alextran1502), or one time donation with Buy Me a coffee link below.
|
||||||
|
|
||||||
|
[](https://www.buymeacoffee.com/altran1502)
|
||||||
|
|
||||||
This is also a meaningful way to give me motivation and encounragment to continue working on the app.
|
This is also a meaningful way to give me motivation and encounragment to continue working on the app.
|
||||||
|
|
||||||
@@ -135,14 +200,22 @@ Cheer! 🎉
|
|||||||
|
|
||||||
# Known Issue
|
# Known Issue
|
||||||
|
|
||||||
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture.
|
## TensorFlow Build Issue
|
||||||
|
|
||||||
|
*This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup*
|
||||||
|
|
||||||
|
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
more /proc/cpuinfo | grep flags
|
more /proc/cpuinfo | grep flags
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are running virtualization in Promox, the VM doesn't have the flag enable.
|
If you are running virtualization in Promox, the VM doesn't have the flag enable.
|
||||||
|
|
||||||
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
||||||
|
|
||||||
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
|
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
|
||||||
|
|
||||||
|
Otherwise you can:
|
||||||
|
- edit `docker-compose.yml` file and comment the whole `immich_microservices` service **which will disable machine learning features like object detection and image classification**
|
||||||
|
- switch to a different VM/desktop with different architecture.
|
||||||
|
|||||||
BIN
design/backup-screen.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
design/google-play-qr-code.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
design/home-screen.jpeg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
design/login-screen.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
design/nsc1.png
|
Before Width: | Height: | Size: 176 KiB |
BIN
design/nsc2.png
|
Before Width: | Height: | Size: 303 KiB |
BIN
design/search-screen.jpeg
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
design/selective-backup-screen.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
design/shared-albums.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
@@ -1,15 +1,15 @@
|
|||||||
# Database
|
# Database
|
||||||
DB_USERNAME=postgres
|
DB_USERNAME=postgres
|
||||||
DB_PASSWORD=postgres
|
DB_PASSWORD=postgres
|
||||||
DB_DATABASE_NAME=
|
DB_DATABASE_NAME=immich
|
||||||
|
|
||||||
# Upload File Config
|
# Upload File Config
|
||||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||||
|
|
||||||
# JWT SECRET
|
# JWT SECRET
|
||||||
JWT_SECRET=
|
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||||
|
|
||||||
# MAPBOX
|
# MAPBOX
|
||||||
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||||
ENABLE_MAPBOX=
|
ENABLE_MAPBOX=false
|
||||||
MAPBOX_KEY=
|
MAPBOX_KEY=
|
||||||
@@ -2,7 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
immich_server:
|
immich_server:
|
||||||
image: immich-server-dev:1.6.0
|
image: immich-server-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- immich_network
|
- immich_network
|
||||||
|
|
||||||
immich_microservices:
|
immich_microservices:
|
||||||
image: immich-microservices-dev:1.6.0
|
image: immich-microservices-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../microservices
|
context: ../microservices
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
immich_server:
|
immich_server:
|
||||||
image: immich-server-dev:1.6.0
|
image: immich-server-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -22,7 +22,7 @@ services:
|
|||||||
- immich_network
|
- immich_network
|
||||||
|
|
||||||
immich_microservices:
|
immich_microservices:
|
||||||
image: immich-microservices-dev:1.6.0
|
image: immich-microservices-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../microservices
|
context: ../microservices
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
immich_server:
|
immich_server:
|
||||||
image: immich-server:1.6.0
|
image: altran1502/immich-server:v1.8.0_12-dev
|
||||||
build:
|
|
||||||
context: ../server
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
@@ -23,10 +20,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
immich_microservices:
|
immich_microservices:
|
||||||
image: immich-microservices:1.6.0
|
image: altran1502/immich-microservices:v1.8.0_12-dev
|
||||||
build:
|
|
||||||
context: ../microservices
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- "3001"
|
||||||
|
|||||||
@@ -10,11 +10,22 @@ map $http_upgrade $connection_upgrade {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
gunzip on;
|
||||||
|
|
||||||
client_max_body_size 50000M;
|
client_max_body_size 50000M;
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
gzip_static on;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
gzip_comp_level 2;
|
||||||
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_buffer_size 16k;
|
proxy_buffer_size 16k;
|
||||||
proxy_busy_buffers_size 24k;
|
proxy_busy_buffers_size 24k;
|
||||||
|
|||||||
@@ -81,4 +81,5 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
implementation 'com.android.support:multidex:1.0.3'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
* New features
|
||||||
|
- Share album. Users can now create albums to share with existing people on the network.
|
||||||
|
- Owner can delete the album.
|
||||||
|
- Owner can invite the additional users to the album.
|
||||||
|
- Shared users and the owner can add additional assets to the album.
|
||||||
|
* In the asset viewer, the user can swipe up to see detailed information and swip down to dismiss.
|
||||||
|
* Several UI enhancements.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Album name is now editable
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* New Feature - Selection backup. User can now select a combination of albums to be included or excluded during the backup process, and only unique photos, and videos that are not overlapping between the two groups will be backup.
|
||||||
|
* Bug fix - Show correct count of backup and remainder assets.
|
||||||
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 517 KiB After Width: | Height: | Size: 681 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 183 KiB |
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.6.0"
|
version_number: "1.9.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ const String userInfoBox = "immichBoxUserInfo"; // Box
|
|||||||
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
|
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
|
||||||
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
||||||
|
|
||||||
// SERVER ENDPOINT
|
// Server endpoint
|
||||||
const String serverEndpointKey = 'immichBoxServerEndpoint';
|
const String serverEndpointKey = 'immichBoxServerEndpoint';
|
||||||
|
|
||||||
// KEY
|
// Login Info
|
||||||
const String hiveAllAsssetKey = "allAssets";
|
const String hiveLoginInfoBox = "immichLoginInfoBox";
|
||||||
const String hiveBackupProgressKey = "backupProgressAssets";
|
const String savedLoginInfoKey = "immichSavedLoginInfoKey";
|
||||||
|
|
||||||
|
// Backup Info
|
||||||
|
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox";
|
||||||
|
const String backupInfoKey = "immichBackupAlbumInfoKey";
|
||||||
|
|||||||
3
mobile/lib/constants/immich_colors.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const immichBackgroundColor = Color(0xFFf6f8fe);
|
||||||
@@ -2,20 +2,28 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/backup.provider.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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
import 'constants/hive_box.dart';
|
import 'constants/hive_box.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
|
|
||||||
|
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||||
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
|
|
||||||
await Hive.openBox(userInfoBox);
|
await Hive.openBox(userInfoBox);
|
||||||
// Hive.registerAdapter(ImmichBackUpAssetAdapter());
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
// Hive.deleteBoxFromDisk(hiveImmichBox);
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
const SystemUiOverlayStyle(
|
||||||
@@ -87,28 +95,33 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp(
|
||||||
title: 'Immich',
|
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
home: Stack(
|
||||||
brightness: Brightness.light,
|
children: [
|
||||||
primarySwatch: Colors.indigo,
|
MaterialApp.router(
|
||||||
// textTheme: GoogleFonts.workSansTextTheme(
|
title: 'Immich',
|
||||||
// Theme.of(context).textTheme.apply(fontSizeFactor: 1.0),
|
debugShowCheckedModeBanner: false,
|
||||||
// ),
|
theme: ThemeData(
|
||||||
fontFamily: 'WorkSans',
|
brightness: Brightness.light,
|
||||||
snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
|
primarySwatch: Colors.indigo,
|
||||||
scaffoldBackgroundColor: const Color(0xFFf6f8fe),
|
fontFamily: 'WorkSans',
|
||||||
appBarTheme: const AppBarTheme(
|
snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
|
||||||
backgroundColor: Colors.white,
|
scaffoldBackgroundColor: immichBackgroundColor,
|
||||||
foregroundColor: Colors.indigo,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 1,
|
backgroundColor: immichBackgroundColor,
|
||||||
centerTitle: true,
|
foregroundColor: Colors.indigo,
|
||||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
elevation: 1,
|
||||||
),
|
centerTitle: true,
|
||||||
|
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
routeInformationParser: _immichRouter.defaultRouteParser(),
|
||||||
|
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
||||||
|
),
|
||||||
|
const ImmichLoadingOverlay(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
routeInformationParser: _immichRouter.defaultRouteParser(),
|
|
||||||
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
@@ -35,6 +37,18 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
assetDetail = await _assetService.getAssetById(asset.id);
|
assetDetail = await _assetService.getAssetById(asset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showInfo() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
isScrollControlled: false,
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
getAssetExif();
|
getAssetExif();
|
||||||
return null;
|
return null;
|
||||||
@@ -44,79 +58,77 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
appBar: TopControlAppBar(
|
appBar: TopControlAppBar(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
onMoreInfoPressed: () {
|
onMoreInfoPressed: showInfo,
|
||||||
showModalBottomSheet(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
barrierColor: Colors.transparent,
|
|
||||||
isScrollControlled: false,
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onDownloadPressed: () {
|
onDownloadPressed: () {
|
||||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SwipeDetector(
|
||||||
child: Stack(
|
onSwipeDown: (_) {
|
||||||
children: [
|
AutoRouter.of(context).pop();
|
||||||
Center(
|
},
|
||||||
child: Hero(
|
onSwipeUp: (_) {
|
||||||
tag: heroTag,
|
showInfo();
|
||||||
child: CachedNetworkImage(
|
},
|
||||||
fit: BoxFit.cover,
|
child: SafeArea(
|
||||||
imageUrl: imageUrl,
|
child: Stack(
|
||||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
children: [
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
Center(
|
||||||
errorWidget: (context, url, error) => ConstrainedBox(
|
child: Hero(
|
||||||
constraints: const BoxConstraints(maxWidth: 300),
|
tag: heroTag,
|
||||||
child: Wrap(
|
child: CachedNetworkImage(
|
||||||
spacing: 32,
|
fit: BoxFit.cover,
|
||||||
runSpacing: 32,
|
imageUrl: imageUrl,
|
||||||
alignment: WrapAlignment.center,
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
children: [
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
const Text(
|
errorWidget: (context, url, error) => ConstrainedBox(
|
||||||
"Failed To Render Image - Possibly Corrupted Data",
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
textAlign: TextAlign.center,
|
child: Wrap(
|
||||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
spacing: 32,
|
||||||
),
|
runSpacing: 32,
|
||||||
SingleChildScrollView(
|
alignment: WrapAlignment.center,
|
||||||
child: Text(
|
children: [
|
||||||
error.toString(),
|
const Text(
|
||||||
|
"Failed To Render Image - Possibly Corrupted Data",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
SingleChildScrollView(
|
||||||
],
|
child: Text(
|
||||||
|
error.toString(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
placeholder: (context, url) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
cacheKey: thumbnailUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
imageUrl: thumbnailUrl,
|
||||||
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
|
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
||||||
|
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||||
|
scale: 0.2,
|
||||||
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.error,
|
||||||
|
color: Colors.grey[300],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
placeholder: (context, url) {
|
|
||||||
return CachedNetworkImage(
|
|
||||||
cacheKey: thumbnailUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
imageUrl: thumbnailUrl,
|
|
||||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
|
||||||
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
|
||||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
|
||||||
scale: 0.2,
|
|
||||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => Icon(
|
|
||||||
Icons.error,
|
|
||||||
color: Colors.grey[300],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
const Center(
|
||||||
const Center(
|
child: DownloadLoadingIndicator(),
|
||||||
child: DownloadLoadingIndicator(),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
@@ -29,6 +31,18 @@ class VideoViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
|
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|
||||||
|
void showInfo() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
isScrollControlled: false,
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getAssetExif() async {
|
getAssetExif() async {
|
||||||
assetDetail = await _assetService.getAssetById(asset.id);
|
assetDetail = await _assetService.getAssetById(asset.id);
|
||||||
}
|
}
|
||||||
@@ -43,32 +57,32 @@ class VideoViewerPage extends HookConsumerWidget {
|
|||||||
appBar: TopControlAppBar(
|
appBar: TopControlAppBar(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
onMoreInfoPressed: () {
|
onMoreInfoPressed: () {
|
||||||
showModalBottomSheet(
|
showInfo();
|
||||||
backgroundColor: Colors.black,
|
|
||||||
barrierColor: Colors.transparent,
|
|
||||||
isScrollControlled: false,
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onDownloadPressed: () {
|
onDownloadPressed: () {
|
||||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SwipeDetector(
|
||||||
child: Stack(
|
onSwipeDown: (_) {
|
||||||
children: [
|
AutoRouter.of(context).pop();
|
||||||
VideoThumbnailPlayer(
|
},
|
||||||
url: videoUrl,
|
onSwipeUp: (_) {
|
||||||
jwtToken: jwtToken,
|
showInfo();
|
||||||
),
|
},
|
||||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
child: SafeArea(
|
||||||
const Center(
|
child: Stack(
|
||||||
child: DownloadLoadingIndicator(),
|
children: [
|
||||||
|
VideoThumbnailPlayer(
|
||||||
|
url: videoUrl,
|
||||||
|
jwtToken: jwtToken,
|
||||||
),
|
),
|
||||||
],
|
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||||
|
const Center(
|
||||||
|
child: DownloadLoadingIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
35
mobile/lib/modules/backup/models/available_album.model.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class AvailableAlbum {
|
||||||
|
final AssetPathEntity albumEntity;
|
||||||
|
final Uint8List? thumbnailData;
|
||||||
|
AvailableAlbum({
|
||||||
|
required this.albumEntity,
|
||||||
|
this.thumbnailData,
|
||||||
|
});
|
||||||
|
|
||||||
|
AvailableAlbum copyWith({
|
||||||
|
AssetPathEntity? albumEntity,
|
||||||
|
Uint8List? thumbnailData,
|
||||||
|
}) {
|
||||||
|
return AvailableAlbum(
|
||||||
|
albumEntity: albumEntity ?? this.albumEntity,
|
||||||
|
thumbnailData: thumbnailData ?? this.thumbnailData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is AvailableAlbum && other.albumEntity == albumEntity && other.thumbnailData == thumbnailData;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
|
||||||
|
}
|
||||||
88
mobile/lib/modules/backup/models/backup_state.model.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||||
|
|
||||||
|
enum BackUpProgressEnum { idle, inProgress, done }
|
||||||
|
|
||||||
|
class BackUpState extends Equatable {
|
||||||
|
// enum
|
||||||
|
final BackUpProgressEnum backupProgress;
|
||||||
|
final List<String> allAssetOnDatabase;
|
||||||
|
final double progressInPercentage;
|
||||||
|
final CancelToken cancelToken;
|
||||||
|
final ServerInfo serverInfo;
|
||||||
|
|
||||||
|
/// All available albums on the device
|
||||||
|
final List<AvailableAlbum> availableAlbums;
|
||||||
|
final Set<AssetPathEntity> selectedBackupAlbums;
|
||||||
|
final Set<AssetPathEntity> excludedBackupAlbums;
|
||||||
|
|
||||||
|
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
||||||
|
final Set<AssetEntity> allUniqueAssets;
|
||||||
|
|
||||||
|
/// All assets from the selected albums that have been backup
|
||||||
|
final Set<String> selectedAlbumsBackupAssetsIds;
|
||||||
|
|
||||||
|
const BackUpState({
|
||||||
|
required this.backupProgress,
|
||||||
|
required this.allAssetOnDatabase,
|
||||||
|
required this.progressInPercentage,
|
||||||
|
required this.cancelToken,
|
||||||
|
required this.serverInfo,
|
||||||
|
required this.availableAlbums,
|
||||||
|
required this.selectedBackupAlbums,
|
||||||
|
required this.excludedBackupAlbums,
|
||||||
|
required this.allUniqueAssets,
|
||||||
|
required this.selectedAlbumsBackupAssetsIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
BackUpState copyWith({
|
||||||
|
BackUpProgressEnum? backupProgress,
|
||||||
|
List<String>? allAssetOnDatabase,
|
||||||
|
double? progressInPercentage,
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
ServerInfo? serverInfo,
|
||||||
|
List<AvailableAlbum>? availableAlbums,
|
||||||
|
Set<AssetPathEntity>? selectedBackupAlbums,
|
||||||
|
Set<AssetPathEntity>? excludedBackupAlbums,
|
||||||
|
Set<AssetEntity>? allUniqueAssets,
|
||||||
|
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||||
|
}) {
|
||||||
|
return BackUpState(
|
||||||
|
backupProgress: backupProgress ?? this.backupProgress,
|
||||||
|
allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase,
|
||||||
|
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
||||||
|
cancelToken: cancelToken ?? this.cancelToken,
|
||||||
|
serverInfo: serverInfo ?? this.serverInfo,
|
||||||
|
availableAlbums: availableAlbums ?? this.availableAlbums,
|
||||||
|
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
||||||
|
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
||||||
|
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
|
||||||
|
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'BackUpState(backupProgress: $backupProgress, allAssetOnDatabase: $allAssetOnDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props {
|
||||||
|
return [
|
||||||
|
backupProgress,
|
||||||
|
allAssetOnDatabase,
|
||||||
|
progressInPercentage,
|
||||||
|
cancelToken,
|
||||||
|
serverInfo,
|
||||||
|
availableAlbums,
|
||||||
|
selectedBackupAlbums,
|
||||||
|
excludedBackupAlbums,
|
||||||
|
allUniqueAssets,
|
||||||
|
selectedAlbumsBackupAssetsIds,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
|
|
||||||
|
part 'hive_backup_albums.model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: 1)
|
||||||
|
class HiveBackupAlbums {
|
||||||
|
@HiveField(0)
|
||||||
|
List<String> selectedAlbumIds;
|
||||||
|
|
||||||
|
@HiveField(1)
|
||||||
|
List<String> excludedAlbumsIds;
|
||||||
|
|
||||||
|
HiveBackupAlbums({
|
||||||
|
required this.selectedAlbumIds,
|
||||||
|
required this.excludedAlbumsIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
|
||||||
|
|
||||||
|
HiveBackupAlbums copyWith({
|
||||||
|
List<String>? selectedAlbumIds,
|
||||||
|
List<String>? excludedAlbumsIds,
|
||||||
|
}) {
|
||||||
|
return HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
|
||||||
|
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'selectedAlbumIds': selectedAlbumIds});
|
||||||
|
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory HiveBackupAlbums.fromMap(Map<String, dynamic> map) {
|
||||||
|
return HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
|
||||||
|
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory HiveBackupAlbums.fromJson(String source) => HiveBackupAlbums.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
|
return other is HiveBackupAlbums &&
|
||||||
|
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
|
||||||
|
listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'hive_backup_albums.model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
|
||||||
|
@override
|
||||||
|
final int typeId = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
HiveBackupAlbums read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: (fields[0] as List).cast<String>(),
|
||||||
|
excludedAlbumsIds: (fields[1] as List).cast<String>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, HiveBackupAlbums obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(2)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.selectedAlbumIds)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.excludedAlbumsIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is HiveBackupAlbumsAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
|
||||||
|
}
|
||||||
347
mobile/lib/modules/backup/providers/backup.provider.dart
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.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/models/available_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
|
BackupNotifier({this.ref})
|
||||||
|
: super(
|
||||||
|
BackUpState(
|
||||||
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
|
allAssetOnDatabase: const [],
|
||||||
|
progressInPercentage: 0,
|
||||||
|
cancelToken: CancelToken(),
|
||||||
|
serverInfo: ServerInfo(
|
||||||
|
diskAvailable: "0",
|
||||||
|
diskAvailableRaw: 0,
|
||||||
|
diskSize: "0",
|
||||||
|
diskSizeRaw: 0,
|
||||||
|
diskUsagePercentage: 0.0,
|
||||||
|
diskUse: "0",
|
||||||
|
diskUseRaw: 0,
|
||||||
|
),
|
||||||
|
availableAlbums: const [],
|
||||||
|
selectedBackupAlbums: const {},
|
||||||
|
excludedBackupAlbums: const {},
|
||||||
|
allUniqueAssets: const {},
|
||||||
|
selectedAlbumsBackupAssetsIds: const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ref? ref;
|
||||||
|
final BackupService _backupService = BackupService();
|
||||||
|
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||||
|
|
||||||
|
///
|
||||||
|
/// UI INTERACTION
|
||||||
|
///
|
||||||
|
/// Album selection
|
||||||
|
/// Due to the overlapping assets across multiple albums on the device
|
||||||
|
/// We have method to include and exclude albums
|
||||||
|
/// The total unique assets will be used for backing mechanism
|
||||||
|
///
|
||||||
|
void addAlbumForBackup(AssetPathEntity album) {
|
||||||
|
if (state.excludedBackupAlbums.contains(album)) {
|
||||||
|
removeExcludedAlbumForBackup(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addExcludedAlbumForBackup(AssetPathEntity album) {
|
||||||
|
if (state.selectedBackupAlbums.contains(album)) {
|
||||||
|
removeAlbumForBackup(album);
|
||||||
|
}
|
||||||
|
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAlbumForBackup(AssetPathEntity album) {
|
||||||
|
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||||
|
|
||||||
|
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||||
|
|
||||||
|
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeExcludedAlbumForBackup(AssetPathEntity album) {
|
||||||
|
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||||
|
|
||||||
|
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||||
|
|
||||||
|
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Get all album on the device
|
||||||
|
/// Get all selected and excluded album from the user's persistent storage
|
||||||
|
/// If this is the first time performing backup - set the default selected album to be
|
||||||
|
/// the one that has all assets (Recent on Android, Recents on iOS)
|
||||||
|
///
|
||||||
|
Future<void> getBackupAlbumsInfo() async {
|
||||||
|
// Get all albums on the device
|
||||||
|
List<AvailableAlbum> availableAlbums = [];
|
||||||
|
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(hasAll: true, type: RequestType.common);
|
||||||
|
|
||||||
|
for (AssetPathEntity album in albums) {
|
||||||
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
|
var assetList = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
|
|
||||||
|
if (assetList.isNotEmpty) {
|
||||||
|
var thumbnailAsset = assetList.first;
|
||||||
|
var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||||
|
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableAlbums.add(availableAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
|
|
||||||
|
// Put persistent storage info into local state of the app
|
||||||
|
// Get local storage on selected backup album
|
||||||
|
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
|
||||||
|
backupInfoKey,
|
||||||
|
defaultValue: HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: [],
|
||||||
|
excludedAlbumsIds: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backupAlbumInfo == null) {
|
||||||
|
debugPrint("[ERROR] getting Hive backup album infomation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First time backup - set isAll album is the default one for backup.
|
||||||
|
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
|
||||||
|
debugPrint("First time backup setup recent album as default");
|
||||||
|
|
||||||
|
// Get album that contains all assets
|
||||||
|
var list = await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
||||||
|
AssetPathEntity albumHasAllAssets = list.first;
|
||||||
|
|
||||||
|
backupAlbumInfoBox.put(
|
||||||
|
backupInfoKey,
|
||||||
|
HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: [albumHasAllAssets.id],
|
||||||
|
excludedAlbumsIds: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
|
||||||
|
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
|
||||||
|
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// From all the selected and albums assets
|
||||||
|
/// Find the assets that are not overlapping between the two sets
|
||||||
|
/// Those assets are unique and are used as the total assets
|
||||||
|
///
|
||||||
|
void _updateBackupAssetCount() async {
|
||||||
|
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||||
|
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
|
for (var album in state.selectedBackupAlbums) {
|
||||||
|
var assets = await album.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);
|
||||||
|
assetsFromExcludedAlbums.addAll(assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||||
|
List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset();
|
||||||
|
|
||||||
|
// Find asset that were backup from selected albums
|
||||||
|
Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id));
|
||||||
|
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
|
||||||
|
|
||||||
|
if (allUniqueAssets.isEmpty) {
|
||||||
|
debugPrint("No Asset On Device");
|
||||||
|
state = state.copyWith(
|
||||||
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
|
allAssetOnDatabase: allAssetOnDatabase,
|
||||||
|
allUniqueAssets: {},
|
||||||
|
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
allAssetOnDatabase: allAssetOnDatabase,
|
||||||
|
allUniqueAssets: allUniqueAssets,
|
||||||
|
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to persistent storage
|
||||||
|
_updatePersistentAlbumsSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Get all necessary information for calculating the available albums,
|
||||||
|
/// which albums are selected or excluded
|
||||||
|
/// and then update the UI according to those information
|
||||||
|
///
|
||||||
|
void getBackupInfo() async {
|
||||||
|
await getBackupAlbumsInfo();
|
||||||
|
_updateServerInfo();
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Save user selection of selected albums and excluded albums to
|
||||||
|
/// Hive database
|
||||||
|
///
|
||||||
|
void _updatePersistentAlbumsSelection() {
|
||||||
|
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
backupAlbumInfoBox.put(
|
||||||
|
backupInfoKey,
|
||||||
|
HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
||||||
|
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Invoke backup process
|
||||||
|
///
|
||||||
|
void startBackupProcess() async {
|
||||||
|
_updateServerInfo();
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||||
|
|
||||||
|
var authResult = await PhotoManager.requestPermissionExtend();
|
||||||
|
if (authResult.isAuth) {
|
||||||
|
await PhotoManager.clearFileCache();
|
||||||
|
|
||||||
|
if (state.allUniqueAssets.isEmpty) {
|
||||||
|
debugPrint("No Asset On Device - Abort Backup Process");
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets;
|
||||||
|
|
||||||
|
// Remove item that has already been backed up
|
||||||
|
for (var assetId in state.allAssetOnDatabase) {
|
||||||
|
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetsWillBeBackup.isEmpty) {
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform Backup
|
||||||
|
state = state.copyWith(cancelToken: CancelToken());
|
||||||
|
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
|
||||||
|
} else {
|
||||||
|
PhotoManager.openSetting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelBackup() {
|
||||||
|
state.cancelToken.cancel('Cancel Backup');
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId},
|
||||||
|
allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]);
|
||||||
|
|
||||||
|
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateServerInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUploadProgress(int sent, int total) {
|
||||||
|
state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateServerInfo() async {
|
||||||
|
var serverInfo = await _serverInfoService.getServerInfo();
|
||||||
|
|
||||||
|
// Update server info
|
||||||
|
state = state.copyWith(
|
||||||
|
serverInfo: ServerInfo(
|
||||||
|
diskSize: serverInfo.diskSize,
|
||||||
|
diskUse: serverInfo.diskUse,
|
||||||
|
diskAvailable: serverInfo.diskAvailable,
|
||||||
|
diskSizeRaw: serverInfo.diskSizeRaw,
|
||||||
|
diskUseRaw: serverInfo.diskUseRaw,
|
||||||
|
diskAvailableRaw: serverInfo.diskAvailableRaw,
|
||||||
|
diskUsagePercentage: serverInfo.diskUsagePercentage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeBackup() {
|
||||||
|
var authState = ref?.read(authenticationProvider);
|
||||||
|
|
||||||
|
// Check if user is login
|
||||||
|
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|
||||||
|
// User has been logged out return
|
||||||
|
if (authState != null) {
|
||||||
|
if (accessKey == null || !authState.isAuthenticated) {
|
||||||
|
debugPrint("[resumeBackup] not authenticated - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this device is enable backup by the user
|
||||||
|
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||||
|
// check if backup is alreayd in process - then return
|
||||||
|
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||||
|
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run backup
|
||||||
|
debugPrint("[resumeBackup] Start back up");
|
||||||
|
startBackupProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||||
|
return BackupNotifier(ref: ref);
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ class BackupService {
|
|||||||
return result.cast<String>();
|
return result.cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
|
backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
|
||||||
Function(int, int) uploadProgress) async {
|
Function(int, int) uploadProgress) async {
|
||||||
var dio = Dio();
|
var dio = Dio();
|
||||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||||
185
mobile/lib/modules/backup/ui/album_info_card.dart
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
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/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;
|
||||||
|
|
||||||
|
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||||
|
final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||||
|
|
||||||
|
ColorFilter selectedFilter = ColorFilter.mode(Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken);
|
||||||
|
ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
|
||||||
|
ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color);
|
||||||
|
|
||||||
|
_buildSelectedTextBox() {
|
||||||
|
if (isSelected) {
|
||||||
|
return Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
|
label: const Text(
|
||||||
|
"INCLUDED",
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
|
} else if (isExcluded) {
|
||||||
|
return Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
|
label: const Text(
|
||||||
|
"EXCLUDED",
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red[300],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildImageFilter() {
|
||||||
|
if (isSelected) {
|
||||||
|
return selectedFilter;
|
||||||
|
} else if (isExcluded) {
|
||||||
|
return excludedFilter;
|
||||||
|
} else {
|
||||||
|
return unselectedFilter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "Cannot remove the only album",
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(backupProvider.notifier).removeAlbumForBackup(albumInfo);
|
||||||
|
} else {
|
||||||
|
ref.watch(backupProvider.notifier).addAlbumForBackup(albumInfo);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDoubleTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
|
||||||
|
if (isExcluded) {
|
||||||
|
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo);
|
||||||
|
} else {
|
||||||
|
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
|
||||||
|
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "Cannot exclude the only album",
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.all(1),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12), // if you need this
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Color(0xFFC9C9C9),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
borderOnForeground: false,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
|
||||||
|
image: DecorationImage(
|
||||||
|
colorFilter: _buildImageFilter(),
|
||||||
|
image: imageData != null
|
||||||
|
? MemoryImage(imageData!)
|
||||||
|
: const AssetImage('assets/immich-logo-no-outline.png') as ImageProvider,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: null,
|
||||||
|
),
|
||||||
|
Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox())
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 25.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
albumInfo.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
albumInfo.assetCount.toString() + (albumInfo.isAll ? " (ALL)" : ""),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo));
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
Icons.image_outlined,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
splashRadius: 25,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
mobile/lib/modules/backup/ui/backup_info_card.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class BackupInfoCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String info;
|
||||||
|
const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5), // if you need this
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Colors.black12,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
borderOnForeground: false,
|
||||||
|
child: ListTile(
|
||||||
|
minVerticalPadding: 15,
|
||||||
|
isThreeLine: true,
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
|
||||||
|
),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
info,
|
||||||
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Text("assets"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
mobile/lib/modules/backup/views/album_preview_page.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class AlbumPreviewPage extends HookConsumerWidget {
|
||||||
|
final AssetPathEntity album;
|
||||||
|
const AlbumPreviewPage({Key? key, required this.album}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final assets = useState<List<AssetEntity>>([]);
|
||||||
|
|
||||||
|
_getAssetsInAlbum() async {
|
||||||
|
assets.value = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
_getAssetsInAlbum();
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
title: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${album.name} (${album.assetCount})",
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
"ID ${album.id}",
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.grey[600], fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () => AutoRouter.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: GridView.builder(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 5,
|
||||||
|
crossAxisSpacing: 2,
|
||||||
|
mainAxisSpacing: 2,
|
||||||
|
),
|
||||||
|
itemCount: assets.value.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
Future<Uint8List?> thumbData =
|
||||||
|
assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50);
|
||||||
|
|
||||||
|
return FutureBuilder<Uint8List?>(
|
||||||
|
future: thumbData,
|
||||||
|
builder: ((context, snapshot) {
|
||||||
|
if (snapshot.hasData && snapshot.data != null) {
|
||||||
|
return Image.memory(
|
||||||
|
snapshot.data!,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: ImmichLoadingIndicator(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
mobile/lib/modules/backup/views/backup_album_selection_page.dart
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/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';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
|
class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||||
|
const BackupAlbumSelectionPage({Key? key}) : super(key: key);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final availableAlbums = ref.watch(backupProvider).availableAlbums;
|
||||||
|
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
|
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
ref.read(backupProvider.notifier).getBackupAlbumsInfo();
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
_buildAlbumSelectionList() {
|
||||||
|
if (availableAlbums.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: ImmichLoadingIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 265,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: availableAlbums.length,
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
itemBuilder: ((context, index) {
|
||||||
|
var thumbnailData = availableAlbums[index].thumbnailData;
|
||||||
|
return Padding(
|
||||||
|
padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0),
|
||||||
|
child: AlbumInfoCard(imageData: thumbnailData, albumInfo: availableAlbums[index].albumEntity),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSelectedAlbumNameChip() {
|
||||||
|
return selectedBackupAlbums.map((album) {
|
||||||
|
void removeSelection() {
|
||||||
|
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "Cannot remove the only album",
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(backupProvider.notifier).removeAlbumForBackup(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: removeSelection,
|
||||||
|
child: Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
|
label: Text(
|
||||||
|
album.name,
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
deleteIconColor: Colors.white,
|
||||||
|
deleteIcon: const Icon(
|
||||||
|
Icons.cancel_rounded,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
onDeleted: removeSelection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildExcludedAlbumNameChip() {
|
||||||
|
return excludedBackupAlbums.map((album) {
|
||||||
|
void removeSelection() {
|
||||||
|
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: removeSelection,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
|
label: Text(
|
||||||
|
album.name,
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red[300],
|
||||||
|
deleteIconColor: Colors.white,
|
||||||
|
deleteIcon: const Icon(
|
||||||
|
Icons.cancel_rounded,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
onDeleted: removeSelection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () => AutoRouter.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
"Select Albums",
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
||||||
|
child: Text(
|
||||||
|
"Selection Info",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Selected Album Chips
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Wrap(
|
||||||
|
children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
|
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),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
borderOnForeground: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: Text(
|
||||||
|
"Total unique assets",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
ref.watch(backupProvider).allUniqueAssets.length.toString(),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
"Albums on device (${availableAlbums.length.toString()})",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
"Tap to include, double tap to exclude",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
splashRadius: 16,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.info,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
// show the dialog
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
elevation: 5,
|
||||||
|
title: Text(
|
||||||
|
'Selection Info',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: ListBody(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: _buildAlbumSelectionList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
|
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||||
|
|
||||||
class BackupControllerPage extends HookConsumerWidget {
|
class BackupControllerPage extends HookConsumerWidget {
|
||||||
@@ -14,13 +16,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
BackUpState _backupState = ref.watch(backupProvider);
|
BackUpState backupState = ref.watch(backupProvider);
|
||||||
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
|
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
|
||||||
|
bool shouldBackup =
|
||||||
bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true;
|
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
||||||
ref.read(backupProvider.notifier).getBackupInfo();
|
ref.read(backupProvider.notifier).getBackupInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +48,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
LinearPercentIndicator(
|
LinearPercentIndicator(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
lineHeight: 5.0,
|
lineHeight: 5.0,
|
||||||
percent: _backupState.serverInfo.diskUsagePercentage / 100.0,
|
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||||
backgroundColor: Colors.grey,
|
backgroundColor: Colors.grey,
|
||||||
progressColor: Theme.of(context).primaryColor,
|
progressColor: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12.0),
|
padding: const EdgeInsets.only(top: 12.0),
|
||||||
child: Text('${_backupState.serverInfo.diskUse} of ${_backupState.serverInfo.diskSize} used'),
|
child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -104,18 +106,120 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectedAlbumName() {
|
||||||
|
var text = "Selected: ";
|
||||||
|
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
|
|
||||||
|
if (albums.isNotEmpty) {
|
||||||
|
for (var album in albums) {
|
||||||
|
if (album.name == "Recent" || album.name == "Recents") {
|
||||||
|
text += "${album.name} (All), ";
|
||||||
|
} else {
|
||||||
|
text += "${album.name}, ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
text.trim().substring(0, text.length - 2),
|
||||||
|
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
"None selected",
|
||||||
|
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExcludedAlbumName() {
|
||||||
|
var text = "Excluded: ";
|
||||||
|
var albums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||||
|
|
||||||
|
if (albums.isNotEmpty) {
|
||||||
|
for (var album in albums) {
|
||||||
|
text += "${album.name}, ";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
text.trim().substring(0, text.length - 2),
|
||||||
|
style: TextStyle(color: Colors.red[300], fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildFolderSelectionTile() {
|
||||||
|
return Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5), // if you need this
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Colors.black12,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
borderOnForeground: false,
|
||||||
|
child: ListTile(
|
||||||
|
minVerticalPadding: 15,
|
||||||
|
title: const Text("Backup Albums", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"Albums to be backup",
|
||||||
|
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
|
||||||
|
),
|
||||||
|
_buildSelectedAlbumName(),
|
||||||
|
_buildExcludedAlbumName()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
|
||||||
|
},
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: 16.0,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"Select",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"Backup",
|
"Backup",
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
||||||
AutoRouter.of(context).pop(true);
|
AutoRouter.of(context).pop(true);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.arrow_back_ios_rounded)),
|
splashRadius: 24,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_back_ios_rounded,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
@@ -129,20 +233,21 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildFolderSelectionTile(),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "Total",
|
title: "Total",
|
||||||
subtitle: "All images and videos on the device",
|
subtitle: "All unique photos and videos from selected albums",
|
||||||
info: "${_backupState.totalAssetCount}",
|
info: "${backupState.allUniqueAssets.length}",
|
||||||
),
|
),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "Backup",
|
title: "Backup",
|
||||||
subtitle: "Images and videos of the device that are backup on server",
|
subtitle: "Photos and videos from selected albums that are backup",
|
||||||
info: "${_backupState.assetOnDatabase}",
|
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||||
),
|
),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "Remainder",
|
title: "Remainder",
|
||||||
subtitle: "Images and videos that has not been backing up",
|
subtitle: "Photos and videos that has not been backing up from selected albums",
|
||||||
info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}",
|
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildBackupController(),
|
_buildBackupController(),
|
||||||
@@ -152,14 +257,14 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
"Asset that were being backup: ${_backupState.backingUpAssetCount} [${_backupState.progressInPercentage.toStringAsFixed(0)}%]"),
|
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
const Text("Backup Progress:"),
|
const Text("Backup Progress:"),
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
||||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
? const CircularProgressIndicator.adaptive()
|
? const CircularProgressIndicator.adaptive()
|
||||||
: const Text("Done"),
|
: const Text("Done"),
|
||||||
]),
|
]),
|
||||||
@@ -167,7 +272,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
child: _backupState.backupProgress == BackUpProgressEnum.inProgress
|
child: backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
? ElevatedButton(
|
? ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
|
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -191,50 +296,3 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BackupInfoCard extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final String info;
|
|
||||||
const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(5), // if you need this
|
|
||||||
side: const BorderSide(
|
|
||||||
color: Colors.black12,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
elevation: 0,
|
|
||||||
borderOnForeground: false,
|
|
||||||
child: ListTile(
|
|
||||||
minVerticalPadding: 15,
|
|
||||||
isThreeLine: true,
|
|
||||||
title: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
|
|
||||||
),
|
|
||||||
subtitle: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Text(
|
|
||||||
subtitle,
|
|
||||||
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
info,
|
|
||||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const Text("assets"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||||
|
|
||||||
class DeleteDialog extends ConsumerWidget {
|
class DeleteDialog extends ConsumerWidget {
|
||||||
|
|||||||
@@ -11,40 +11,44 @@ class ImageGrid extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return SliverGrid(
|
return SliverGrid(
|
||||||
gridDelegate:
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5),
|
crossAxisCount: 4,
|
||||||
|
crossAxisSpacing: 5.0,
|
||||||
|
mainAxisSpacing: 5,
|
||||||
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
var assetType = assetGroup[index].type;
|
var assetType = assetGroup[index].type;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ThumbnailImage(asset: assetGroup[index]),
|
ThumbnailImage(asset: assetGroup[index]),
|
||||||
assetType == 'IMAGE'
|
assetType == 'IMAGE'
|
||||||
? Container()
|
? Container()
|
||||||
: Positioned(
|
: Positioned(
|
||||||
top: 5,
|
top: 5,
|
||||||
right: 5,
|
right: 5,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
assetGroup[index].duration.toString().substring(0, 7),
|
assetGroup[index].duration.toString().substring(0, 7),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const Icon(
|
||||||
)
|
Icons.play_circle_outline_rounded,
|
||||||
],
|
color: Colors.white,
|
||||||
));
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
childCount: assetGroup.length,
|
childCount: assetGroup.length,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
|
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||||
import 'package:immich_mobile/shared/providers/backup.provider.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/server_info.provider.dart';
|
||||||
|
|
||||||
class ImmichSliverAppBar extends ConsumerWidget {
|
class ImmichSliverAppBar extends ConsumerWidget {
|
||||||
@@ -29,7 +29,6 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
floating: true,
|
floating: true,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
snap: false,
|
snap: false,
|
||||||
backgroundColor: Colors.grey[200],
|
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||||
leading: Builder(
|
leading: Builder(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
@@ -40,7 +39,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
splashRadius: 25,
|
splashRadius: 25,
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.account_circle_rounded,
|
Icons.face_outlined,
|
||||||
size: 30,
|
size: 30,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -131,7 +130,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
? Positioned(
|
? Positioned(
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Text(
|
child: Text(
|
||||||
_backupState.backingUpAssetCount.toString(),
|
(_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
|
||||||
|
.toString(),
|
||||||
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.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/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||||
import 'package:immich_mobile/shared/providers/backup.provider.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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -79,7 +79,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"Sign Out",
|
"Sign Out",
|
||||||
style: TextStyle(color: Colors.black54, fontSize: 14),
|
style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ 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/immich_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
|
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
class HomePage extends HookConsumerWidget {
|
||||||
const HomePage({Key? key}) : super(key: key);
|
const HomePage({Key? key}) : super(key: key);
|
||||||
@@ -37,6 +36,19 @@ class HomePage extends HookConsumerWidget {
|
|||||||
ref.read(assetProvider.notifier).getAllAsset();
|
ref.read(assetProvider.notifier).getAllAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_buildSelectedItemCountIndicator() {
|
||||||
|
return isMultiSelectEnable
|
||||||
|
? DisableMultiSelectButton(
|
||||||
|
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
||||||
|
selectedItemCount: homePageState.selectedItems.length,
|
||||||
|
)
|
||||||
|
: Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBottomAppBar() {
|
||||||
|
return isMultiSelectEnable ? const ControlBottomAppBar() : Container();
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (assetGroupByDateTime.isNotEmpty) {
|
if (assetGroupByDateTime.isNotEmpty) {
|
||||||
int? lastMonth;
|
int? lastMonth;
|
||||||
@@ -70,49 +82,51 @@ class HomePage extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_buildSliverAppBar() {
|
||||||
|
return isMultiSelectEnable
|
||||||
|
? const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 70,
|
||||||
|
child: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ImmichSliverAppBar(
|
||||||
|
onPopBack: reloadAllAsset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: !isMultiSelectEnable,
|
bottom: !isMultiSelectEnable,
|
||||||
top: !isMultiSelectEnable,
|
top: !isMultiSelectEnable,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
DraggableScrollbar.semicircle(
|
CustomScrollView(
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
slivers: [
|
||||||
controller: _scrollController,
|
_buildSliverAppBar(),
|
||||||
heightScrollThumb: 48.0,
|
],
|
||||||
child: CustomScrollView(
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 50.0),
|
||||||
|
child: DraggableScrollbar.semicircle(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
heightScrollThumb: 48.0,
|
||||||
SliverAnimatedSwitcher(
|
child: CustomScrollView(
|
||||||
child: isMultiSelectEnable
|
controller: _scrollController,
|
||||||
? const SliverToBoxAdapter(
|
slivers: [
|
||||||
child: SizedBox(
|
..._imageGridGroup,
|
||||||
height: 70,
|
],
|
||||||
child: null,
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
: ImmichSliverAppBar(
|
|
||||||
onPopBack: reloadAllAsset,
|
|
||||||
),
|
|
||||||
duration: const Duration(milliseconds: 350),
|
|
||||||
),
|
|
||||||
..._imageGridGroup
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
isMultiSelectEnable
|
_buildSelectedItemCountIndicator(),
|
||||||
? DisableMultiSelectButton(
|
_buildBottomAppBar(),
|
||||||
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
|
||||||
selectedItemCount: homePageState.selectedItems.length,
|
|
||||||
)
|
|
||||||
: Container(),
|
|
||||||
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
// key: _scaffoldKey,
|
|
||||||
drawer: const ProfileDrawer(),
|
drawer: const ProfileDrawer(),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:hive/hive.dart';
|
||||||
|
|
||||||
|
part 'hive_saved_login_info.model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: 0)
|
||||||
|
class HiveSavedLoginInfo {
|
||||||
|
@HiveField(0)
|
||||||
|
String email;
|
||||||
|
|
||||||
|
@HiveField(1)
|
||||||
|
String password;
|
||||||
|
|
||||||
|
@HiveField(2)
|
||||||
|
String serverUrl;
|
||||||
|
|
||||||
|
@HiveField(3)
|
||||||
|
bool isSaveLogin;
|
||||||
|
|
||||||
|
HiveSavedLoginInfo({required this.email, required this.password, required this.serverUrl, required this.isSaveLogin});
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'hive_saved_login_info.model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
|
||||||
|
@override
|
||||||
|
final int typeId = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
HiveSavedLoginInfo read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return HiveSavedLoginInfo(
|
||||||
|
email: fields[0] as String,
|
||||||
|
password: fields[1] as String,
|
||||||
|
serverUrl: fields[2] as String,
|
||||||
|
isSaveLogin: fields[3] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, HiveSavedLoginInfo obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(4)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.email)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.password)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.serverUrl)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.isSaveLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is HiveSavedLoginInfoAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import 'package:hive/hive.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/login_response.model.dart';
|
import 'package:immich_mobile/modules/login/models/login_response.model.dart';
|
||||||
import 'package:immich_mobile/shared/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/device_info.model.dart';
|
import 'package:immich_mobile/shared/models/device_info.model.dart';
|
||||||
@@ -36,7 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
final BackupService _backupService = BackupService();
|
final BackupService _backupService = BackupService();
|
||||||
final NetworkService _networkService = NetworkService();
|
final NetworkService _networkService = NetworkService();
|
||||||
|
|
||||||
Future<bool> login(String email, String password, String serverEndpoint) async {
|
Future<bool> login(String email, String password, String serverEndpoint, bool isSavedLoginInfo) async {
|
||||||
// Store server endpoint to Hive and test endpoint
|
// Store server endpoint to Hive and test endpoint
|
||||||
if (serverEndpoint[serverEndpoint.length - 1] == "/") {
|
if (serverEndpoint[serverEndpoint.length - 1] == "/") {
|
||||||
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
|
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
|
||||||
@@ -76,6 +77,20 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
userEmail: payload.userEmail,
|
userEmail: payload.userEmail,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isSavedLoginInfo) {
|
||||||
|
// Save login info to local storage
|
||||||
|
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
||||||
|
savedLoginInfoKey,
|
||||||
|
HiveSavedLoginInfo(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
isSaveLogin: true,
|
||||||
|
serverUrl: Hive.box(userInfoBox).get(serverEndpointKey)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.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/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
class LoginForm extends HookConsumerWidget {
|
class LoginForm extends HookConsumerWidget {
|
||||||
@@ -12,22 +15,36 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final usernameController = useTextEditingController(text: 'testuser@email.com');
|
final usernameController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final passwordController = useTextEditingController(text: 'password');
|
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
|
final serverEndpointController = useTextEditingController(text: 'http://your-server-ip:2283');
|
||||||
|
final isSaveLoginInfo = useState<bool>(false);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
|
||||||
|
|
||||||
|
if (loginInfo != null) {
|
||||||
|
usernameController.text = loginInfo.email;
|
||||||
|
passwordController.text = loginInfo.password;
|
||||||
|
serverEndpointController.text = loginInfo.serverUrl;
|
||||||
|
isSaveLoginInfo.value = loginInfo.isSaveLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 300),
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 32,
|
spacing: 16,
|
||||||
runSpacing: 32,
|
runSpacing: 16,
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Image(
|
const Image(
|
||||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||||
width: 128,
|
width: 100,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -42,10 +59,29 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
EmailInput(controller: usernameController),
|
EmailInput(controller: usernameController),
|
||||||
PasswordInput(controller: passwordController),
|
PasswordInput(controller: passwordController),
|
||||||
ServerEndpointInput(controller: serverEndpointController),
|
ServerEndpointInput(controller: serverEndpointController),
|
||||||
|
CheckboxListTile(
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
dense: true,
|
||||||
|
side: const BorderSide(color: Colors.grey, width: 1.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
|
enableFeedback: true,
|
||||||
|
title: const Text(
|
||||||
|
"Save login",
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||||
|
),
|
||||||
|
value: isSaveLoginInfo.value,
|
||||||
|
onChanged: (switchValue) {
|
||||||
|
if (switchValue != null) {
|
||||||
|
isSaveLoginInfo.value = switchValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
LoginButton(
|
LoginButton(
|
||||||
emailController: usernameController,
|
emailController: usernameController,
|
||||||
passwordController: passwordController,
|
passwordController: passwordController,
|
||||||
serverEndpointController: serverEndpointController,
|
serverEndpointController: serverEndpointController,
|
||||||
|
isSavedLoginInfo: isSaveLoginInfo.value,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -104,29 +140,34 @@ class LoginButton extends ConsumerWidget {
|
|||||||
final TextEditingController emailController;
|
final TextEditingController emailController;
|
||||||
final TextEditingController passwordController;
|
final TextEditingController passwordController;
|
||||||
final TextEditingController serverEndpointController;
|
final TextEditingController serverEndpointController;
|
||||||
|
final bool isSavedLoginInfo;
|
||||||
|
|
||||||
const LoginButton(
|
const LoginButton({
|
||||||
{Key? key,
|
Key? key,
|
||||||
required this.emailController,
|
required this.emailController,
|
||||||
required this.passwordController,
|
required this.passwordController,
|
||||||
required this.serverEndpointController})
|
required this.serverEndpointController,
|
||||||
: super(key: key);
|
required this.isSavedLoginInfo,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
visualDensity: VisualDensity.standard,
|
||||||
|
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 10, horizontal: 25)),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// This will remove current cache asset state of previous user login.
|
// This will remove current cache asset state of previous user login.
|
||||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||||
|
|
||||||
var isAuthenicated = await ref
|
var isAuthenticated = await ref
|
||||||
.read(authenticationProvider.notifier)
|
.read(authenticationProvider.notifier)
|
||||||
.login(emailController.text, passwordController.text, serverEndpointController.text);
|
.login(emailController.text, passwordController.text, serverEndpointController.text, isSavedLoginInfo);
|
||||||
|
|
||||||
if (isAuthenicated) {
|
if (isAuthenticated) {
|
||||||
// Resume backup (if enable) then navigate
|
// Resume backup (if enable) then navigate
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
ref.watch(backupProvider.notifier).resumeBackup();
|
||||||
// AutoRouter.of(context).pushNamed("/home-page");
|
|
||||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||||
} else {
|
} else {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
@@ -136,6 +177,9 @@ class LoginButton extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text("Login"));
|
child: const Text(
|
||||||
|
"Login",
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
@@ -11,6 +12,7 @@ import 'package:immich_mobile/modules/search/ui/search_bar.dart';
|
|||||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
@@ -23,8 +25,10 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var box = Hive.box(userInfoBox);
|
var box = Hive.box(userInfoBox);
|
||||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||||
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
|
AsyncValue<List<CuratedLocation>> curatedLocation =
|
||||||
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
|
ref.watch(getCuratedLocationProvider);
|
||||||
|
AsyncValue<List<CuratedObject>> curatedObjects =
|
||||||
|
ref.watch(getCuratedObjectProvider);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
searchFocusNode = FocusNode();
|
searchFocusNode = FocusNode();
|
||||||
@@ -40,7 +44,10 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
_buildPlaces() {
|
_buildPlaces() {
|
||||||
return curatedLocation.when(
|
return curatedLocation.when(
|
||||||
loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()),
|
loading: () => const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(child: ImmichLoadingIndicator()),
|
||||||
|
),
|
||||||
error: (err, stack) => Text('Error: $err'),
|
error: (err, stack) => Text('Error: $err'),
|
||||||
data: (curatedLocations) {
|
data: (curatedLocations) {
|
||||||
return curatedLocations.isNotEmpty
|
return curatedLocations.isNotEmpty
|
||||||
@@ -59,7 +66,8 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
imageUrl: thumbnailRequestUrl,
|
imageUrl: thumbnailRequestUrl,
|
||||||
textInfo: locationInfo.city,
|
textInfo: locationInfo.city,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city));
|
AutoRouter.of(context).push(
|
||||||
|
SearchResultRoute(searchTerm: locationInfo.city));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -87,7 +95,10 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
_buildThings() {
|
_buildThings() {
|
||||||
return curatedObjects.when(
|
return curatedObjects.when(
|
||||||
loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()),
|
loading: () => const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(child: ImmichLoadingIndicator()),
|
||||||
|
),
|
||||||
error: (err, stack) => Text('Error: $err'),
|
error: (err, stack) => Text('Error: $err'),
|
||||||
data: (objects) {
|
data: (objects) {
|
||||||
return objects.isNotEmpty
|
return objects.isNotEmpty
|
||||||
@@ -106,8 +117,9 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
imageUrl: thumbnailRequestUrl,
|
imageUrl: thumbnailRequestUrl,
|
||||||
textInfo: curatedObjectInfo.object,
|
textInfo: curatedObjectInfo.object,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
AutoRouter.of(context)
|
AutoRouter.of(context).push(SearchResultRoute(
|
||||||
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
|
searchTerm: curatedObjectInfo.object
|
||||||
|
.capitalizeFirstLetter()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -165,7 +177,9 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
_buildThings()
|
_buildThings()
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
isSearchEnabled
|
||||||
|
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
|
||||||
|
: Container(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class AlbumViewerPageState {
|
||||||
|
final bool isEditAlbum;
|
||||||
|
final String editTitleText;
|
||||||
|
AlbumViewerPageState({
|
||||||
|
required this.isEditAlbum,
|
||||||
|
required this.editTitleText,
|
||||||
|
});
|
||||||
|
|
||||||
|
AlbumViewerPageState copyWith({
|
||||||
|
bool? isEditAlbum,
|
||||||
|
String? editTitleText,
|
||||||
|
}) {
|
||||||
|
return AlbumViewerPageState(
|
||||||
|
isEditAlbum: isEditAlbum ?? this.isEditAlbum,
|
||||||
|
editTitleText: editTitleText ?? this.editTitleText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'isEditAlbum': isEditAlbum});
|
||||||
|
result.addAll({'editTitleText': editTitleText});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AlbumViewerPageState.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AlbumViewerPageState(
|
||||||
|
isEditAlbum: map['isEditAlbum'] ?? false,
|
||||||
|
editTitleText: map['editTitleText'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory AlbumViewerPageState.fromJson(String source) => AlbumViewerPageState.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is AlbumViewerPageState && other.isEditAlbum == isEditAlbum && other.editTitleText == editTitleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => isEditAlbum.hashCode ^ editTitleText.hashCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class AssetSelectionPageResult {
|
||||||
|
final Set<ImmichAsset> selectedNewAsset;
|
||||||
|
final Set<ImmichAsset> selectedAdditionalAsset;
|
||||||
|
final bool isAlbumExist;
|
||||||
|
|
||||||
|
AssetSelectionPageResult({
|
||||||
|
required this.selectedNewAsset,
|
||||||
|
required this.selectedAdditionalAsset,
|
||||||
|
required this.isAlbumExist,
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetSelectionPageResult copyWith({
|
||||||
|
Set<ImmichAsset>? selectedNewAsset,
|
||||||
|
Set<ImmichAsset>? selectedAdditionalAsset,
|
||||||
|
bool? isAlbumExist,
|
||||||
|
}) {
|
||||||
|
return AssetSelectionPageResult(
|
||||||
|
selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset,
|
||||||
|
selectedAdditionalAsset: selectedAdditionalAsset ?? this.selectedAdditionalAsset,
|
||||||
|
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'selectedNewAsset': selectedNewAsset.map((x) => x.toMap()).toList()});
|
||||||
|
result.addAll({'selectedAdditionalAsset': selectedAdditionalAsset.map((x) => x.toMap()).toList()});
|
||||||
|
result.addAll({'isAlbumExist': isAlbumExist});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AssetSelectionPageResult.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AssetSelectionPageResult(
|
||||||
|
selectedNewAsset: Set<ImmichAsset>.from(map['selectedNewAsset']?.map((x) => ImmichAsset.fromMap(x))),
|
||||||
|
selectedAdditionalAsset:
|
||||||
|
Set<ImmichAsset>.from(map['selectedAdditionalAsset']?.map((x) => ImmichAsset.fromMap(x))),
|
||||||
|
isAlbumExist: map['isAlbumExist'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory AssetSelectionPageResult.fromJson(String source) => AssetSelectionPageResult.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'AssetSelectionPageResult(selectedNewAsset: $selectedNewAsset, selectedAdditionalAsset: $selectedAdditionalAsset, isAlbumExist: $isAlbumExist)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
final setEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
|
return other is AssetSelectionPageResult &&
|
||||||
|
setEquals(other.selectedNewAsset, selectedNewAsset) &&
|
||||||
|
setEquals(other.selectedAdditionalAsset, selectedAdditionalAsset) &&
|
||||||
|
other.isAlbumExist == isAlbumExist;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => selectedNewAsset.hashCode ^ selectedAdditionalAsset.hashCode ^ isAlbumExist.hashCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class AssetSelectionState {
|
||||||
|
final Set<String> selectedMonths;
|
||||||
|
final Set<ImmichAsset> selectedNewAssetsForAlbum;
|
||||||
|
final Set<ImmichAsset> selectedAdditionalAssetsForAlbum;
|
||||||
|
final Set<ImmichAsset> selectedAssetsInAlbumViewer;
|
||||||
|
final bool isMultiselectEnable;
|
||||||
|
|
||||||
|
/// Indicate the asset selection page is navigated from existing album
|
||||||
|
final bool isAlbumExist;
|
||||||
|
AssetSelectionState({
|
||||||
|
required this.selectedMonths,
|
||||||
|
required this.selectedNewAssetsForAlbum,
|
||||||
|
required this.selectedAdditionalAssetsForAlbum,
|
||||||
|
required this.selectedAssetsInAlbumViewer,
|
||||||
|
required this.isMultiselectEnable,
|
||||||
|
required this.isAlbumExist,
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetSelectionState copyWith({
|
||||||
|
Set<String>? selectedMonths,
|
||||||
|
Set<ImmichAsset>? selectedNewAssetsForAlbum,
|
||||||
|
Set<ImmichAsset>? selectedAdditionalAssetsForAlbum,
|
||||||
|
Set<ImmichAsset>? selectedAssetsInAlbumViewer,
|
||||||
|
bool? isMultiselectEnable,
|
||||||
|
bool? isAlbumExist,
|
||||||
|
}) {
|
||||||
|
return AssetSelectionState(
|
||||||
|
selectedMonths: selectedMonths ?? this.selectedMonths,
|
||||||
|
selectedNewAssetsForAlbum: selectedNewAssetsForAlbum ?? this.selectedNewAssetsForAlbum,
|
||||||
|
selectedAdditionalAssetsForAlbum: selectedAdditionalAssetsForAlbum ?? this.selectedAdditionalAssetsForAlbum,
|
||||||
|
selectedAssetsInAlbumViewer: selectedAssetsInAlbumViewer ?? this.selectedAssetsInAlbumViewer,
|
||||||
|
isMultiselectEnable: isMultiselectEnable ?? this.isMultiselectEnable,
|
||||||
|
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'selectedMonths': selectedMonths.toList()});
|
||||||
|
result.addAll({'selectedNewAssetsForAlbum': selectedNewAssetsForAlbum.map((x) => x.toMap()).toList()});
|
||||||
|
result
|
||||||
|
.addAll({'selectedAdditionalAssetsForAlbum': selectedAdditionalAssetsForAlbum.map((x) => x.toMap()).toList()});
|
||||||
|
result.addAll({'selectedAssetsInAlbumViewer': selectedAssetsInAlbumViewer.map((x) => x.toMap()).toList()});
|
||||||
|
result.addAll({'isMultiselectEnable': isMultiselectEnable});
|
||||||
|
result.addAll({'isAlbumExist': isAlbumExist});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AssetSelectionState.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AssetSelectionState(
|
||||||
|
selectedMonths: Set<String>.from(map['selectedMonths']),
|
||||||
|
selectedNewAssetsForAlbum:
|
||||||
|
Set<ImmichAsset>.from(map['selectedNewAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
|
||||||
|
selectedAdditionalAssetsForAlbum:
|
||||||
|
Set<ImmichAsset>.from(map['selectedAdditionalAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
|
||||||
|
selectedAssetsInAlbumViewer:
|
||||||
|
Set<ImmichAsset>.from(map['selectedAssetsInAlbumViewer']?.map((x) => ImmichAsset.fromMap(x))),
|
||||||
|
isMultiselectEnable: map['isMultiselectEnable'] ?? false,
|
||||||
|
isAlbumExist: map['isAlbumExist'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory AssetSelectionState.fromJson(String source) => AssetSelectionState.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AssetSelectionState(selectedMonths: $selectedMonths, selectedNewAssetsForAlbum: $selectedNewAssetsForAlbum, selectedAdditionalAssetsForAlbum: $selectedAdditionalAssetsForAlbum, selectedAssetsInAlbumViewer: $selectedAssetsInAlbumViewer, isMultiselectEnable: $isMultiselectEnable, isAlbumExist: $isAlbumExist)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
final setEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
|
return other is AssetSelectionState &&
|
||||||
|
setEquals(other.selectedMonths, selectedMonths) &&
|
||||||
|
setEquals(other.selectedNewAssetsForAlbum, selectedNewAssetsForAlbum) &&
|
||||||
|
setEquals(other.selectedAdditionalAssetsForAlbum, selectedAdditionalAssetsForAlbum) &&
|
||||||
|
setEquals(other.selectedAssetsInAlbumViewer, selectedAssetsInAlbumViewer) &&
|
||||||
|
other.isMultiselectEnable == isMultiselectEnable &&
|
||||||
|
other.isAlbumExist == isAlbumExist;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return selectedMonths.hashCode ^
|
||||||
|
selectedNewAssetsForAlbum.hashCode ^
|
||||||
|
selectedAdditionalAssetsForAlbum.hashCode ^
|
||||||
|
selectedAssetsInAlbumViewer.hashCode ^
|
||||||
|
isMultiselectEnable.hashCode ^
|
||||||
|
isAlbumExist.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
mobile/lib/modules/sharing/models/shared_album.model.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart';
|
||||||
|
|
||||||
|
class SharedAlbum {
|
||||||
|
final String id;
|
||||||
|
final String ownerId;
|
||||||
|
final String albumName;
|
||||||
|
final String createdAt;
|
||||||
|
final String? albumThumbnailAssetId;
|
||||||
|
final List<SharedUsers> sharedUsers;
|
||||||
|
final List<SharedAssets>? sharedAssets;
|
||||||
|
|
||||||
|
SharedAlbum({
|
||||||
|
required this.id,
|
||||||
|
required this.ownerId,
|
||||||
|
required this.albumName,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.albumThumbnailAssetId,
|
||||||
|
required this.sharedUsers,
|
||||||
|
this.sharedAssets,
|
||||||
|
});
|
||||||
|
|
||||||
|
SharedAlbum copyWith({
|
||||||
|
String? id,
|
||||||
|
String? ownerId,
|
||||||
|
String? albumName,
|
||||||
|
String? createdAt,
|
||||||
|
String? albumThumbnailAssetId,
|
||||||
|
List<SharedUsers>? sharedUsers,
|
||||||
|
List<SharedAssets>? sharedAssets,
|
||||||
|
}) {
|
||||||
|
return SharedAlbum(
|
||||||
|
id: id ?? this.id,
|
||||||
|
ownerId: ownerId ?? this.ownerId,
|
||||||
|
albumName: albumName ?? this.albumName,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId,
|
||||||
|
sharedUsers: sharedUsers ?? this.sharedUsers,
|
||||||
|
sharedAssets: sharedAssets ?? this.sharedAssets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'id': id});
|
||||||
|
result.addAll({'ownerId': ownerId});
|
||||||
|
result.addAll({'albumName': albumName});
|
||||||
|
result.addAll({'createdAt': createdAt});
|
||||||
|
if (albumThumbnailAssetId != null) {
|
||||||
|
result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId});
|
||||||
|
}
|
||||||
|
result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()});
|
||||||
|
if (sharedAssets != null) {
|
||||||
|
result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SharedAlbum.fromMap(Map<String, dynamic> map) {
|
||||||
|
return SharedAlbum(
|
||||||
|
id: map['id'] ?? '',
|
||||||
|
ownerId: map['ownerId'] ?? '',
|
||||||
|
albumName: map['albumName'] ?? '',
|
||||||
|
createdAt: map['createdAt'] ?? '',
|
||||||
|
albumThumbnailAssetId: map['albumThumbnailAssetId'],
|
||||||
|
sharedUsers: List<SharedUsers>.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))),
|
||||||
|
sharedAssets: map['sharedAssets'] != null
|
||||||
|
? List<SharedAssets>.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x)))
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory SharedAlbum.fromJson(String source) => SharedAlbum.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, sharedAssets: $sharedAssets)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
|
return other is SharedAlbum &&
|
||||||
|
other.id == id &&
|
||||||
|
other.ownerId == ownerId &&
|
||||||
|
other.albumName == albumName &&
|
||||||
|
other.createdAt == createdAt &&
|
||||||
|
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||||
|
listEquals(other.sharedUsers, sharedUsers) &&
|
||||||
|
listEquals(other.sharedAssets, sharedAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return id.hashCode ^
|
||||||
|
ownerId.hashCode ^
|
||||||
|
albumName.hashCode ^
|
||||||
|
createdAt.hashCode ^
|
||||||
|
albumThumbnailAssetId.hashCode ^
|
||||||
|
sharedUsers.hashCode ^
|
||||||
|
sharedAssets.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
mobile/lib/modules/sharing/models/shared_asset.model.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class SharedAssets {
|
||||||
|
final ImmichAsset assetInfo;
|
||||||
|
|
||||||
|
SharedAssets({
|
||||||
|
required this.assetInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
SharedAssets copyWith({
|
||||||
|
ImmichAsset? assetInfo,
|
||||||
|
}) {
|
||||||
|
return SharedAssets(
|
||||||
|
assetInfo: assetInfo ?? this.assetInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'assetInfo': assetInfo.toMap()});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SharedAssets.fromMap(Map<String, dynamic> map) {
|
||||||
|
return SharedAssets(
|
||||||
|
assetInfo: ImmichAsset.fromMap(map['assetInfo']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SharedAssets(assetInfo: $assetInfo)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is SharedAssets && other.assetInfo == assetInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => assetInfo.hashCode;
|
||||||
|
}
|
||||||
76
mobile/lib/modules/sharing/models/shared_user.model.dart
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||||
|
|
||||||
|
class SharedUsers {
|
||||||
|
final int id;
|
||||||
|
final String albumId;
|
||||||
|
final String sharedUserId;
|
||||||
|
final UserInfo userInfo;
|
||||||
|
|
||||||
|
SharedUsers({
|
||||||
|
required this.id,
|
||||||
|
required this.albumId,
|
||||||
|
required this.sharedUserId,
|
||||||
|
required this.userInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
SharedUsers copyWith({
|
||||||
|
int? id,
|
||||||
|
String? albumId,
|
||||||
|
String? sharedUserId,
|
||||||
|
UserInfo? userInfo,
|
||||||
|
}) {
|
||||||
|
return SharedUsers(
|
||||||
|
id: id ?? this.id,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
|
sharedUserId: sharedUserId ?? this.sharedUserId,
|
||||||
|
userInfo: userInfo ?? this.userInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'id': id});
|
||||||
|
result.addAll({'albumId': albumId});
|
||||||
|
result.addAll({'sharedUserId': sharedUserId});
|
||||||
|
result.addAll({'userInfo': userInfo.toMap()});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SharedUsers.fromMap(Map<String, dynamic> map) {
|
||||||
|
return SharedUsers(
|
||||||
|
id: map['id']?.toInt() ?? 0,
|
||||||
|
albumId: map['albumId'] ?? '',
|
||||||
|
sharedUserId: map['sharedUserId'] ?? '',
|
||||||
|
userInfo: UserInfo.fromMap(map['userInfo']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is SharedUsers &&
|
||||||
|
other.id == id &&
|
||||||
|
other.albumId == albumId &&
|
||||||
|
other.sharedUserId == sharedUserId &&
|
||||||
|
other.userInfo == userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class AlbumTitleNotifier extends StateNotifier<String> {
|
||||||
|
AlbumTitleNotifier() : super("");
|
||||||
|
|
||||||
|
setAlbumTitle(String title) {
|
||||||
|
state = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAlbumTitle() {
|
||||||
|
state = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final albumTitleProvider = StateNotifierProvider<AlbumTitleNotifier, String>((ref) => AlbumTitleNotifier());
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/album_viewer_page_state.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||||
|
|
||||||
|
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
|
||||||
|
AlbumViewerNotifier(this.ref) : super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false));
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
void enableEditAlbum() {
|
||||||
|
state = state.copyWith(isEditAlbum: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void disableEditAlbum() {
|
||||||
|
state = state.copyWith(isEditAlbum: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEditTitleText(String newTitle) {
|
||||||
|
state = state.copyWith(editTitleText: newTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remoteEditTitleText() {
|
||||||
|
state = state.copyWith(editTitleText: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetState() {
|
||||||
|
state = state.copyWith(editTitleText: "", isEditAlbum: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> changeAlbumTitle(String albumId, String ownerId, String newAlbumTitle) async {
|
||||||
|
SharedAlbumService service = SharedAlbumService();
|
||||||
|
|
||||||
|
bool isSuccess = await service.changeTitleAlbum(albumId, ownerId, newAlbumTitle);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
state = state.copyWith(editTitleText: "", isEditAlbum: false);
|
||||||
|
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(editTitleText: "", isEditAlbum: false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final albumViewerProvider = StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) {
|
||||||
|
return AlbumViewerNotifier(ref);
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/asset_selection_state.model.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
|
||||||
|
AssetSelectionNotifier()
|
||||||
|
: super(AssetSelectionState(
|
||||||
|
selectedNewAssetsForAlbum: {},
|
||||||
|
selectedMonths: {},
|
||||||
|
selectedAdditionalAssetsForAlbum: {},
|
||||||
|
selectedAssetsInAlbumViewer: {},
|
||||||
|
isAlbumExist: false,
|
||||||
|
isMultiselectEnable: false,
|
||||||
|
));
|
||||||
|
|
||||||
|
void setIsAlbumExist(bool isAlbumExist) {
|
||||||
|
state = state.copyWith(isAlbumExist: isAlbumExist);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAssetsInMonth(String removedMonth, List<ImmichAsset> assetsInMonth) {
|
||||||
|
Set<ImmichAsset> currentAssetList = state.selectedNewAssetsForAlbum;
|
||||||
|
Set<String> currentMonthList = state.selectedMonths;
|
||||||
|
|
||||||
|
currentMonthList.removeWhere((selectedMonth) => selectedMonth == removedMonth);
|
||||||
|
|
||||||
|
for (ImmichAsset asset in assetsInMonth) {
|
||||||
|
currentAssetList.removeWhere((e) => e.id == asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(selectedNewAssetsForAlbum: currentAssetList, selectedMonths: currentMonthList);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAdditionalAssets(List<ImmichAsset> assets) {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedAdditionalAssetsForAlbum: {...state.selectedAdditionalAssetsForAlbum, ...assets},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAllAssetsInMonth(String month, List<ImmichAsset> assetsInMonth) {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedMonths: {...state.selectedMonths, month},
|
||||||
|
selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assetsInMonth},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addNewAssets(List<ImmichAsset> assets) {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assets},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeSelectedNewAssets(List<ImmichAsset> assets) {
|
||||||
|
Set<ImmichAsset> currentList = state.selectedNewAssetsForAlbum;
|
||||||
|
|
||||||
|
for (ImmichAsset asset in assets) {
|
||||||
|
currentList.removeWhere((e) => e.id == asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(selectedNewAssetsForAlbum: currentList);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeSelectedAdditionalAssets(List<ImmichAsset> assets) {
|
||||||
|
Set<ImmichAsset> currentList = state.selectedAdditionalAssetsForAlbum;
|
||||||
|
|
||||||
|
for (ImmichAsset asset in assets) {
|
||||||
|
currentList.removeWhere((e) => e.id == asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(selectedAdditionalAssetsForAlbum: currentList);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAll() {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedNewAssetsForAlbum: {},
|
||||||
|
selectedMonths: {},
|
||||||
|
selectedAdditionalAssetsForAlbum: {},
|
||||||
|
selectedAssetsInAlbumViewer: {},
|
||||||
|
isAlbumExist: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void enableMultiselection() {
|
||||||
|
state = state.copyWith(isMultiselectEnable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void disableMultiselection() {
|
||||||
|
state = state.copyWith(
|
||||||
|
isMultiselectEnable: false,
|
||||||
|
selectedAssetsInAlbumViewer: {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAssetsInAlbumViewer(List<ImmichAsset> assets) {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedAssetsInAlbumViewer: {...state.selectedAssetsInAlbumViewer, ...assets},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAssetsInAlbumViewer(List<ImmichAsset> assets) {
|
||||||
|
Set<ImmichAsset> currentList = state.selectedAssetsInAlbumViewer;
|
||||||
|
|
||||||
|
for (ImmichAsset asset in assets) {
|
||||||
|
currentList.removeWhere((e) => e.id == asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(selectedAssetsInAlbumViewer: currentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetSelectionProvider = StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) {
|
||||||
|
return AssetSelectionNotifier();
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||||
|
|
||||||
|
class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
|
||||||
|
SharedAlbumNotifier() : super([]);
|
||||||
|
|
||||||
|
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
|
||||||
|
|
||||||
|
getAllSharedAlbums() async {
|
||||||
|
List<SharedAlbum> sharedAlbums = await _sharedAlbumService.getAllSharedAlbum();
|
||||||
|
|
||||||
|
state = sharedAlbums;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deleteAlbum(String albumId) async {
|
||||||
|
var res = await _sharedAlbumService.deleteAlbum(albumId);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
state = state.where((album) => album.id != albumId).toList();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> leaveAlbum(String albumId) async {
|
||||||
|
var res = await _sharedAlbumService.leaveAlbum(albumId);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
state = state.where((album) => album.id != albumId).toList();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
|
||||||
|
var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final sharedAlbumProvider = StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) {
|
||||||
|
return SharedAlbumNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
final sharedAlbumDetailProvider = FutureProvider.autoDispose.family<SharedAlbum, String>((ref, albumId) async {
|
||||||
|
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
|
||||||
|
|
||||||
|
return await _sharedAlbumService.getAlbumDetail(albumId);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||||
|
|
||||||
|
final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<UserInfo>>((ref) async {
|
||||||
|
UserService userService = UserService();
|
||||||
|
|
||||||
|
return await userService.getAllUsersInfo();
|
||||||
|
});
|
||||||
160
mobile/lib/modules/sharing/services/shared_album.service.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
|
|
||||||
|
class SharedAlbumService {
|
||||||
|
final NetworkService _networkService = NetworkService();
|
||||||
|
|
||||||
|
Future<List<SharedAlbum>> getAllSharedAlbum() async {
|
||||||
|
try {
|
||||||
|
var res = await _networkService.getRequest(url: 'shared/allSharedAlbums');
|
||||||
|
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
List<SharedAlbum> result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e)));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error getAllSharedAlbum ${e.toString()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets, List<String> sharedUserIds) async {
|
||||||
|
try {
|
||||||
|
var res = await _networkService.postRequest(url: 'shared/createAlbum', data: {
|
||||||
|
"albumName": albumName,
|
||||||
|
"sharedWithUserIds": sharedUserIds,
|
||||||
|
"assetIds": assets.map((asset) => asset.id).toList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SharedAlbum> getAlbumDetail(String albumId) async {
|
||||||
|
try {
|
||||||
|
var res = await _networkService.getRequest(url: 'shared/$albumId');
|
||||||
|
dynamic decodedData = jsonDecode(res.toString());
|
||||||
|
SharedAlbum result = SharedAlbum.fromMap(decodedData);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error getAllSharedAlbum ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> addAdditionalAssetToAlbum(Set<ImmichAsset> assets, String albumId) async {
|
||||||
|
try {
|
||||||
|
var res = await _networkService.postRequest(url: 'shared/addAssets', data: {
|
||||||
|
"albumId": albumId,
|
||||||
|
"assetIds": assets.map((asset) => asset.id).toList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> addAdditionalUserToAlbum(List<String> sharedUserIds, String albumId) async {
|
||||||
|
try {
|
||||||
|
var res = await _networkService.postRequest(url: 'shared/addUsers', data: {
|
||||||
|
"albumId": albumId,
|
||||||
|
"sharedUserIds": sharedUserIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deleteAlbum(String albumId) async {
|
||||||
|
try {
|
||||||
|
Response res = await _networkService.deleteRequest(url: 'shared/$albumId');
|
||||||
|
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> leaveAlbum(String albumId) async {
|
||||||
|
try {
|
||||||
|
Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId');
|
||||||
|
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
|
||||||
|
try {
|
||||||
|
Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: {
|
||||||
|
"albumId": albumId,
|
||||||
|
"assetIds": assetIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> changeTitleAlbum(String albumId, String ownerId, String newAlbumTitle) async {
|
||||||
|
try {
|
||||||
|
Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: {
|
||||||
|
"albumId": albumId,
|
||||||
|
"ownerId": ownerId,
|
||||||
|
"albumName": newAlbumTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AlbumActionOutlinedButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final String labelText;
|
||||||
|
final IconData iconData;
|
||||||
|
|
||||||
|
const AlbumActionOutlinedButton({Key? key, this.onPressed, required this.labelText, required this.iconData})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
style: ButtonStyle(
|
||||||
|
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 0, horizontal: 10)),
|
||||||
|
shape: MaterialStateProperty.resolveWith<OutlinedBorder>(
|
||||||
|
(_) => RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
side: MaterialStateProperty.resolveWith<BorderSide>(
|
||||||
|
(_) => const BorderSide(width: 1, color: Color.fromARGB(255, 158, 158, 158)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icon(iconData, size: 15),
|
||||||
|
label: Text(
|
||||||
|
labelText,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
mobile/lib/modules/sharing/ui/album_title_text_field.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart';
|
||||||
|
|
||||||
|
class AlbumTitleTextField extends ConsumerWidget {
|
||||||
|
const AlbumTitleTextField({
|
||||||
|
Key? key,
|
||||||
|
required this.isAlbumTitleEmpty,
|
||||||
|
required this.albumTitleTextFieldFocusNode,
|
||||||
|
required this.albumTitleController,
|
||||||
|
required this.isAlbumTitleTextFieldFocus,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final ValueNotifier<bool> isAlbumTitleEmpty;
|
||||||
|
final FocusNode albumTitleTextFieldFocusNode;
|
||||||
|
final TextEditingController albumTitleController;
|
||||||
|
final ValueNotifier<bool> isAlbumTitleTextFieldFocus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return TextField(
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v.isEmpty) {
|
||||||
|
isAlbumTitleEmpty.value = true;
|
||||||
|
} else {
|
||||||
|
isAlbumTitleEmpty.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(albumTitleProvider.notifier).setAlbumTitle(v);
|
||||||
|
},
|
||||||
|
focusNode: albumTitleTextFieldFocusNode,
|
||||||
|
style: TextStyle(fontSize: 28, color: Colors.grey[700], fontWeight: FontWeight.bold),
|
||||||
|
controller: albumTitleController,
|
||||||
|
onTap: () {
|
||||||
|
isAlbumTitleTextFieldFocus.value = true;
|
||||||
|
|
||||||
|
if (albumTitleController.text == 'Untitled') {
|
||||||
|
albumTitleController.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
suffixIcon: !isAlbumTitleEmpty.value && isAlbumTitleTextFieldFocus.value
|
||||||
|
? IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
albumTitleController.clear();
|
||||||
|
isAlbumTitleEmpty.value = true;
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.cancel_rounded),
|
||||||
|
splashRadius: 10,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
hintText: 'Add a title',
|
||||||
|
focusColor: Colors.grey[300],
|
||||||
|
fillColor: Colors.grey[200],
|
||||||
|
filled: isAlbumTitleTextFieldFocus.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
mobile/lib/modules/sharing/ui/album_viewer_appbar.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
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/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
|
|
||||||
|
class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||||
|
const AlbumViewerAppbar({
|
||||||
|
Key? key,
|
||||||
|
required AsyncValue<SharedAlbum> albumInfo,
|
||||||
|
required this.userId,
|
||||||
|
required this.albumId,
|
||||||
|
}) : _albumInfo = albumInfo,
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
final AsyncValue<SharedAlbum> _albumInfo;
|
||||||
|
final String userId;
|
||||||
|
final String albumId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||||
|
final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||||
|
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||||
|
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||||
|
|
||||||
|
void _onDeleteAlbumPressed(String albumId) async {
|
||||||
|
ImmichLoadingOverlayController.appLoader.show();
|
||||||
|
|
||||||
|
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||||
|
} else {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "Failed to delete album",
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichLoadingOverlayController.appLoader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLeaveAlbumPressed(String albumId) async {
|
||||||
|
ImmichLoadingOverlayController.appLoader.show();
|
||||||
|
|
||||||
|
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "Failed to leave album",
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichLoadingOverlayController.appLoader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRemoveFromAlbumPressed(String albumId) async {
|
||||||
|
ImmichLoadingOverlayController.appLoader.show();
|
||||||
|
|
||||||
|
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
|
||||||
|
albumId,
|
||||||
|
selectedAssetsInAlbum.map((a) => a.id).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||||
|
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "There are problems in removing assets from album",
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichLoadingOverlayController.appLoader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBottomSheetActionButton() {
|
||||||
|
if (isMultiSelectionEnable) {
|
||||||
|
if (_albumInfo.asData?.value.ownerId == userId) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.delete_sweep_rounded),
|
||||||
|
title: const Text(
|
||||||
|
'Remove from album',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onTap: () => _onRemoveFromAlbumPressed(albumId),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_albumInfo.asData?.value.ownerId == userId) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.delete_forever_rounded),
|
||||||
|
title: const Text(
|
||||||
|
'Delete album',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onTap: () => _onDeleteAlbumPressed(albumId),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.person_remove_rounded),
|
||||||
|
title: const Text(
|
||||||
|
'Leave album',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onTap: () => _onLeaveAlbumPressed(albumId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _buildBottomSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
backgroundColor: immichBackgroundColor,
|
||||||
|
isScrollControlled: false,
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildBottomSheetActionButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildLeadingButton() {
|
||||||
|
if (isMultiSelectionEnable) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () => ref.watch(assetSelectionProvider.notifier).disableMultiselection(),
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
splashRadius: 25,
|
||||||
|
);
|
||||||
|
} else if (isEditAlbum) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
bool isSuccess =
|
||||||
|
await ref.watch(albumViewerProvider.notifier).changeAlbumTitle(albumId, userId, newAlbumTitle);
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "Failed to change album title",
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
toastType: ToastType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.check_rounded),
|
||||||
|
splashRadius: 25,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () async => await AutoRouter.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||||
|
splashRadius: 25,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
leading: _buildLeadingButton(),
|
||||||
|
title: isMultiSelectionEnable ? Text(selectedAssetsInAlbum.length.toString()) : Container(),
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
splashRadius: 25,
|
||||||
|
onPressed: _buildBottomSheet,
|
||||||
|
icon: const Icon(Icons.more_horiz_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.dart';
|
||||||
|
|
||||||
|
class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||||
|
final SharedAlbum albumInfo;
|
||||||
|
final FocusNode titleFocusNode;
|
||||||
|
const AlbumViewerEditableTitle({Key? key, required this.albumInfo, required this.titleFocusNode}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final titleTextEditController = useTextEditingController(text: albumInfo.albumName);
|
||||||
|
|
||||||
|
void onFocusModeChange() {
|
||||||
|
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
|
||||||
|
ref.watch(albumViewerProvider.notifier).setEditTitleText("Untitled");
|
||||||
|
titleTextEditController.text = "Untitled";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
titleFocusNode.addListener(onFocusModeChange);
|
||||||
|
return () {
|
||||||
|
titleFocusNode.removeListener(onFocusModeChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return TextField(
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value.isEmpty) {
|
||||||
|
} else {
|
||||||
|
ref.watch(albumViewerProvider.notifier).setEditTitleText(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focusNode: titleFocusNode,
|
||||||
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
controller: titleTextEditController,
|
||||||
|
onTap: () {
|
||||||
|
FocusScope.of(context).requestFocus(titleFocusNode);
|
||||||
|
|
||||||
|
ref.watch(albumViewerProvider.notifier).setEditTitleText(albumInfo.albumName);
|
||||||
|
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
|
||||||
|
|
||||||
|
if (titleTextEditController.text == 'Untitled') {
|
||||||
|
titleTextEditController.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
suffixIcon: titleFocusNode.hasFocus
|
||||||
|
? IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
titleTextEditController.clear();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.cancel_rounded),
|
||||||
|
splashRadius: 10,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
focusColor: Colors.grey[300],
|
||||||
|
fillColor: Colors.grey[200],
|
||||||
|
filled: titleFocusNode.hasFocus,
|
||||||
|
hintText: 'Add a title',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
mobile/lib/modules/sharing/ui/album_viewer_thumbnail.dart
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||||
|
final ImmichAsset asset;
|
||||||
|
|
||||||
|
const AlbumViewerThumbnail({Key? key, required this.asset}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cacheKey = useState(1);
|
||||||
|
var box = Hive.box(userInfoBox);
|
||||||
|
var thumbnailRequestUrl =
|
||||||
|
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
|
||||||
|
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||||
|
final selectedAssetsInAlbumViewer = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||||
|
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||||
|
|
||||||
|
_viewAsset() {
|
||||||
|
if (asset.type == 'IMAGE') {
|
||||||
|
AutoRouter.of(context).push(
|
||||||
|
ImageViewerRoute(
|
||||||
|
imageUrl:
|
||||||
|
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
|
||||||
|
heroTag: asset.id,
|
||||||
|
thumbnailUrl: thumbnailRequestUrl,
|
||||||
|
asset: asset,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
AutoRouter.of(context).push(
|
||||||
|
VideoViewerRoute(
|
||||||
|
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
|
||||||
|
asset: asset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxBorder drawBorderColor() {
|
||||||
|
if (selectedAssetsInAlbumViewer.contains(asset)) {
|
||||||
|
return Border.all(
|
||||||
|
color: Theme.of(context).primaryColorLight,
|
||||||
|
width: 10,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const Border();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_enableMultiSelection() {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_disableMultiSelection() {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildVideoLabel() {
|
||||||
|
if (asset.type == 'IMAGE') {
|
||||||
|
return Container();
|
||||||
|
} else {
|
||||||
|
return Positioned(
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
asset.duration.toString().substring(0, 7),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildAssetStoreLocationIcon() {
|
||||||
|
return Positioned(
|
||||||
|
right: 10,
|
||||||
|
bottom: 5,
|
||||||
|
child: Icon(
|
||||||
|
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildAssetSelectionIcon() {
|
||||||
|
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
|
||||||
|
if (isMultiSelectionEnable) {
|
||||||
|
return Positioned(
|
||||||
|
left: 10,
|
||||||
|
top: 5,
|
||||||
|
child: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildThumbnailImage() {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(border: drawBorderColor()),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
memCacheHeight: 200,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
imageUrl: thumbnailRequestUrl,
|
||||||
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
|
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||||
|
scale: 0.2,
|
||||||
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
return Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleSelectionGesture() {
|
||||||
|
if (selectedAssetsInAlbumViewer.contains(asset)) {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAssetsInAlbumViewer([asset]);
|
||||||
|
|
||||||
|
if (selectedAssetsInAlbumViewer.isEmpty) {
|
||||||
|
_disableMultiSelection();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isMultiSelectionEnable ? _handleSelectionGesture : _viewAsset,
|
||||||
|
onLongPress: _enableMultiSelection,
|
||||||
|
child: Hero(
|
||||||
|
tag: asset.id,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
_buildThumbnailImage(),
|
||||||
|
_buildAssetStoreLocationIcon(),
|
||||||
|
_buildVideoLabel(),
|
||||||
|
_buildAssetSelectionIcon(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
mobile/lib/modules/sharing/ui/asset_grid_by_month.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/selection_thumbnail_image.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class AssetGridByMonth extends HookConsumerWidget {
|
||||||
|
final List<ImmichAsset> assetGroup;
|
||||||
|
const AssetGridByMonth({Key? key, required this.assetGroup}) : super(key: key);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 4,
|
||||||
|
crossAxisSpacing: 5.0,
|
||||||
|
mainAxisSpacing: 5,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(BuildContext context, int index) {
|
||||||
|
return SelectionThumbnailImage(asset: assetGroup[index]);
|
||||||
|
},
|
||||||
|
childCount: assetGroup.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
mobile/lib/modules/sharing/ui/month_group_title.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class MonthGroupTitle extends HookConsumerWidget {
|
||||||
|
final String month;
|
||||||
|
final List<ImmichAsset> assetGroup;
|
||||||
|
|
||||||
|
const MonthGroupTitle({Key? key, required this.month, required this.assetGroup}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths;
|
||||||
|
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||||
|
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||||
|
|
||||||
|
_handleTitleIconClick() {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
|
||||||
|
if (isAlbumExist) {
|
||||||
|
if (selectedDateGroup.contains(month)) {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, []);
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets(assetGroup);
|
||||||
|
} else {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, []);
|
||||||
|
|
||||||
|
// Deep clone assetGroup
|
||||||
|
var assetGroupWithNewItems = [...assetGroup];
|
||||||
|
|
||||||
|
for (var selectedAsset in selectedAssets) {
|
||||||
|
assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets(assetGroupWithNewItems);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedDateGroup.contains(month)) {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, assetGroup);
|
||||||
|
} else {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, assetGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getSimplifiedMonth() {
|
||||||
|
var monthAndYear = month.split(',');
|
||||||
|
var yearText = monthAndYear[1].trim();
|
||||||
|
var monthText = monthAndYear[0].trim();
|
||||||
|
var currentYear = DateTime.now().year.toString();
|
||||||
|
|
||||||
|
if (yearText == currentYear) {
|
||||||
|
return monthText;
|
||||||
|
} else {
|
||||||
|
return month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 14.0, right: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _handleTitleIconClick,
|
||||||
|
child: selectedDateGroup.contains(month)
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.circle_outlined,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Text(
|
||||||
|
_getSimplifiedMonth(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class SelectionThumbnailImage extends HookConsumerWidget {
|
||||||
|
final ImmichAsset asset;
|
||||||
|
|
||||||
|
const SelectionThumbnailImage({Key? key, required this.asset}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cacheKey = useState(1);
|
||||||
|
var box = Hive.box(userInfoBox);
|
||||||
|
var thumbnailRequestUrl =
|
||||||
|
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
|
||||||
|
var selectedAsset = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||||
|
var newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||||
|
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||||
|
|
||||||
|
Widget _buildSelectionIcon(ImmichAsset asset) {
|
||||||
|
if (selectedAsset.contains(asset) && !isAlbumExist) {
|
||||||
|
return Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
|
} else if (selectedAsset.contains(asset) && isAlbumExist) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Color.fromARGB(255, 233, 233, 233),
|
||||||
|
);
|
||||||
|
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
|
||||||
|
return Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const Icon(
|
||||||
|
Icons.circle_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxBorder drawBorderColor() {
|
||||||
|
if (selectedAsset.contains(asset) && !isAlbumExist) {
|
||||||
|
return Border.all(
|
||||||
|
color: Theme.of(context).primaryColorLight,
|
||||||
|
width: 10,
|
||||||
|
);
|
||||||
|
} else if (selectedAsset.contains(asset) && isAlbumExist) {
|
||||||
|
return Border.all(
|
||||||
|
color: const Color.fromARGB(255, 190, 190, 190),
|
||||||
|
width: 10,
|
||||||
|
);
|
||||||
|
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
|
||||||
|
return Border.all(
|
||||||
|
color: Theme.of(context).primaryColorLight,
|
||||||
|
width: 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Border();
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (isAlbumExist) {
|
||||||
|
// Operation for existing album
|
||||||
|
if (!selectedAsset.contains(asset)) {
|
||||||
|
if (newAssetsForAlbum.contains(asset)) {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets([asset]);
|
||||||
|
} else {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets([asset]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Operation for new album
|
||||||
|
if (selectedAsset.contains(asset)) {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeSelectedNewAssets([asset]);
|
||||||
|
} else {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Hero(
|
||||||
|
tag: asset.id,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(border: drawBorderColor()),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
imageUrl: thumbnailRequestUrl,
|
||||||
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
|
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||||
|
scale: 0.2,
|
||||||
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
return Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(3.0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: _buildSelectionIcon(asset),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
asset.type == 'IMAGE'
|
||||||
|
? Container()
|
||||||
|
: Positioned(
|
||||||
|
bottom: 5,
|
||||||
|
right: 5,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
asset.duration.toString().substring(0, 7),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
|
|
||||||
|
class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||||
|
final ImmichAsset asset;
|
||||||
|
|
||||||
|
const SharedAlbumThumbnailImage({Key? key, required this.asset}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cacheKey = useState(1);
|
||||||
|
|
||||||
|
var box = Hive.box(userInfoBox);
|
||||||
|
var thumbnailRequestUrl =
|
||||||
|
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// debugPrint("View ${asset.id}");
|
||||||
|
},
|
||||||
|
child: Hero(
|
||||||
|
tag: asset.id,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||||
|
width: 500,
|
||||||
|
height: 500,
|
||||||
|
memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
imageUrl: thumbnailRequestUrl,
|
||||||
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
|
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||||
|
scale: 0.2,
|
||||||
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
return Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
mobile/lib/modules/sharing/ui/sharing_sliver_appbar.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class SharingSliverAppBar extends StatelessWidget {
|
||||||
|
const SharingSliverAppBar({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
centerTitle: true,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
snap: false,
|
||||||
|
leading: Container(),
|
||||||
|
// elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
'IMMICH',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SnowburstOne',
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 22,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(50.0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
AutoRouter.of(context).push(const CreateSharedAlbumRoute());
|
||||||
|
},
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.photo_album_outlined,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
"Create shared album",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
onPressed: null,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.swap_horizontal_circle_outlined,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
"Share with partner",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
255
mobile/lib/modules/sharing/views/album_viewer_page.dart
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/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/sharing/models/asset_selection_page_result.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/album_viewer_appbar.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/album_viewer_editable_title.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/album_viewer_thumbnail.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||||
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class AlbumViewerPage extends HookConsumerWidget {
|
||||||
|
final String albumId;
|
||||||
|
|
||||||
|
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
FocusNode titleFocusNode = useFocusNode();
|
||||||
|
ScrollController _scrollController = useScrollController();
|
||||||
|
AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId));
|
||||||
|
|
||||||
|
final userId = ref.watch(authenticationProvider).userId;
|
||||||
|
|
||||||
|
/// Find out if the assets in album exist on the device
|
||||||
|
/// If they exist, add to selected asset state to show they are already selected.
|
||||||
|
void _onAddPhotosPressed(SharedAlbum albumInfo) async {
|
||||||
|
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||||
|
ref
|
||||||
|
.watch(assetSelectionProvider.notifier)
|
||||||
|
.addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
|
||||||
|
|
||||||
|
AssetSelectionPageResult? returnPayload =
|
||||||
|
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||||
|
|
||||||
|
if (returnPayload != null) {
|
||||||
|
// Check if there is new assets add
|
||||||
|
if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
|
||||||
|
ImmichLoadingOverlayController.appLoader.show();
|
||||||
|
|
||||||
|
var isSuccess =
|
||||||
|
await SharedAlbumService().addAdditionalAssetToAlbum(returnPayload.selectedAdditionalAsset, albumId);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichLoadingOverlayController.appLoader.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||||
|
} else {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAddUsersPressed(SharedAlbum albumInfo) async {
|
||||||
|
List<String>? sharedUserIds =
|
||||||
|
await AutoRouter.of(context).push<List<String>?>(SelectAdditionalUserForSharingRoute(albumInfo: albumInfo));
|
||||||
|
|
||||||
|
if (sharedUserIds != null) {
|
||||||
|
ImmichLoadingOverlayController.appLoader.show();
|
||||||
|
|
||||||
|
var isSuccess = await SharedAlbumService().addAdditionalUserToAlbum(sharedUserIds, albumId);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichLoadingOverlayController.appLoader.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle(SharedAlbum albumInfo) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
||||||
|
child: userId == albumInfo.ownerId
|
||||||
|
? AlbumViewerEditableTitle(
|
||||||
|
albumInfo: albumInfo,
|
||||||
|
titleFocusNode: titleFocusNode,
|
||||||
|
)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Text(albumInfo.albumName, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
|
||||||
|
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||||
|
String startDate = "";
|
||||||
|
DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt);
|
||||||
|
DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt);
|
||||||
|
|
||||||
|
if (parsedStartDate.year == parsedEndDate.year) {
|
||||||
|
startDate = DateFormat('LLL d').format(parsedStartDate);
|
||||||
|
} else {
|
||||||
|
startDate = DateFormat('LLL d, y').format(parsedStartDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0, top: 8),
|
||||||
|
child: Text(
|
||||||
|
"$startDate-$endDate",
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(SharedAlbum albumInfo) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTitle(albumInfo),
|
||||||
|
_buildAlbumDateRange(albumInfo),
|
||||||
|
SizedBox(
|
||||||
|
height: 60,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(left: 16),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemBuilder: ((context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: Colors.grey[300],
|
||||||
|
radius: 18,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2.0),
|
||||||
|
child: ClipRRect(
|
||||||
|
child: Image.asset('assets/immich-logo-no-outline.png'),
|
||||||
|
borderRadius: BorderRadius.circular(50.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
itemCount: albumInfo.sharedUsers.length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageGrid(SharedAlbum albumInfo) {
|
||||||
|
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 5.0,
|
||||||
|
mainAxisSpacing: 5,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(BuildContext context, int index) {
|
||||||
|
return AlbumViewerThumbnail(asset: albumInfo.sharedAssets![index].assetInfo);
|
||||||
|
},
|
||||||
|
childCount: albumInfo.sharedAssets?.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SliverToBoxAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControlButton(SharedAlbum albumInfo) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
AlbumActionOutlinedButton(
|
||||||
|
iconData: Icons.add_photo_alternate_outlined,
|
||||||
|
onPressed: () => _onAddPhotosPressed(albumInfo),
|
||||||
|
labelText: "Add photos",
|
||||||
|
),
|
||||||
|
userId == albumInfo.ownerId
|
||||||
|
? AlbumActionOutlinedButton(
|
||||||
|
iconData: Icons.person_add_alt_rounded,
|
||||||
|
onPressed: () => _onAddUsersPressed(albumInfo),
|
||||||
|
labelText: "Add users",
|
||||||
|
)
|
||||||
|
: Container(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(SharedAlbum albumInfo) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
titleFocusNode.unfocus();
|
||||||
|
},
|
||||||
|
child: DraggableScrollbar.semicircle(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
controller: _scrollController,
|
||||||
|
heightScrollThumb: 48.0,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
_buildHeader(albumInfo),
|
||||||
|
SliverPersistentHeader(
|
||||||
|
pinned: true,
|
||||||
|
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||||
|
minHeight: 50,
|
||||||
|
maxHeight: 50,
|
||||||
|
child: Container(
|
||||||
|
color: immichBackgroundColor,
|
||||||
|
child: _buildControlButton(albumInfo),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildImageGrid(albumInfo)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AlbumViewerAppbar(albumInfo: _albumInfo, userId: userId, albumId: albumId),
|
||||||
|
body: _albumInfo.when(
|
||||||
|
data: (albumInfo) => _buildBody(albumInfo),
|
||||||
|
error: (e, _) => Center(child: Text("Error loading album info $e")),
|
||||||
|
loading: () => const Center(
|
||||||
|
child: ImmichLoadingIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
mobile/lib/modules/sharing/views/asset_selection_page.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/asset_grid_by_month.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/month_group_title.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||||
|
|
||||||
|
class AssetSelectionPage extends HookConsumerWidget {
|
||||||
|
const AssetSelectionPage({Key? key}) : super(key: key);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
ScrollController _scrollController = useScrollController();
|
||||||
|
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider);
|
||||||
|
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||||
|
final newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||||
|
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||||
|
|
||||||
|
List<Widget> _imageGridGroup = [];
|
||||||
|
|
||||||
|
String _buildAssetCountText() {
|
||||||
|
if (isAlbumExist) {
|
||||||
|
return (selectedAssets.length + newAssetsForAlbum.length).toString();
|
||||||
|
} else {
|
||||||
|
return selectedAssets.length.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
assetGroupMonthYear.forEach((monthYear, assetGroup) {
|
||||||
|
_imageGridGroup.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
|
||||||
|
_imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
DraggableScrollbar.semicircle(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
controller: _scrollController,
|
||||||
|
heightScrollThumb: 48.0,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [..._imageGridGroup],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||||
|
AutoRouter.of(context).pop(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: selectedAssets.isEmpty
|
||||||
|
? const Text(
|
||||||
|
'Add photos',
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_buildAssetCountText(),
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
(!isAlbumExist && selectedAssets.isNotEmpty) || (isAlbumExist && newAssetsForAlbum.isNotEmpty)
|
||||||
|
? TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
var payload = AssetSelectionPageResult(
|
||||||
|
isAlbumExist: isAlbumExist,
|
||||||
|
selectedAdditionalAsset: newAssetsForAlbum,
|
||||||
|
selectedNewAsset: selectedAssets,
|
||||||
|
);
|
||||||
|
AutoRouter.of(context).pop(payload);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
"Add",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
mobile/lib/modules/sharing/views/create_shared_album_page.dart
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/album_title_text_field.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/shared_album_thumbnail_image.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||||
|
const CreateSharedAlbumPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
|
final albumTitleTextFieldFocusNode = useFocusNode();
|
||||||
|
final isAlbumTitleTextFieldFocus = useState(false);
|
||||||
|
final isAlbumTitleEmpty = useState(true);
|
||||||
|
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||||
|
|
||||||
|
_showSelectUserPage() {
|
||||||
|
AutoRouter.of(context).push(const SelectUserForSharingRoute());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onBackgroundTapped() {
|
||||||
|
albumTitleTextFieldFocusNode.unfocus();
|
||||||
|
isAlbumTitleTextFieldFocus.value = false;
|
||||||
|
|
||||||
|
if (albumTitleController.text.isEmpty) {
|
||||||
|
albumTitleController.text = 'Untitled';
|
||||||
|
ref.watch(albumTitleProvider.notifier).setAlbumTitle('Untitled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSelectPhotosButtonPressed() async {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
|
||||||
|
|
||||||
|
AssetSelectionPageResult? selectedAsset =
|
||||||
|
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||||
|
|
||||||
|
if (selectedAsset == null) {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTitleInputField() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
right: 10,
|
||||||
|
left: 10,
|
||||||
|
),
|
||||||
|
child: AlbumTitleTextField(
|
||||||
|
isAlbumTitleEmpty: isAlbumTitleEmpty,
|
||||||
|
albumTitleTextFieldFocusNode: albumTitleTextFieldFocusNode,
|
||||||
|
albumTitleController: albumTitleController,
|
||||||
|
isAlbumTitleTextFieldFocus: isAlbumTitleTextFieldFocus),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTitle() {
|
||||||
|
if (selectedAssets.isEmpty) {
|
||||||
|
return const SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(top: 200, left: 18),
|
||||||
|
child: Text(
|
||||||
|
'ADD ASSETS',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SliverToBoxAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSelectPhotosButton() {
|
||||||
|
if (selectedAssets.isEmpty) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16, left: 18, right: 18),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
style: ButtonStyle(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
padding:
|
||||||
|
MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 22, horizontal: 16)),
|
||||||
|
),
|
||||||
|
onPressed: _onSelectPhotosButtonPressed,
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'Select Photos',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[700], fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SliverToBoxAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildControlButton() {
|
||||||
|
if (selectedAssets.isNotEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 30,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
AlbumActionOutlinedButton(
|
||||||
|
iconData: Icons.add_photo_alternate_outlined,
|
||||||
|
onPressed: _onSelectPhotosButtonPressed,
|
||||||
|
labelText: "Add photos",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSelectedImageGrid() {
|
||||||
|
if (selectedAssets.isNotEmpty) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 5.0,
|
||||||
|
mainAxisSpacing: 5,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(BuildContext context, int index) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _onBackgroundTapped,
|
||||||
|
child: SharedAlbumThumbnailImage(asset: selectedAssets.toList()[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: selectedAssets.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SliverToBoxAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||||
|
AutoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.close_rounded)),
|
||||||
|
title: const Text(
|
||||||
|
'Create album',
|
||||||
|
style: TextStyle(color: Colors.black),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: albumTitleController.text.isNotEmpty ? _showSelectUserPage : null,
|
||||||
|
child: const Text(
|
||||||
|
'Share',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: GestureDetector(
|
||||||
|
onTap: _onBackgroundTapped,
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
elevation: 5,
|
||||||
|
leading: Container(),
|
||||||
|
pinned: true,
|
||||||
|
floating: false,
|
||||||
|
bottom: PreferredSize(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildTitleInputField(),
|
||||||
|
_buildControlButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
preferredSize: const Size.fromHeight(66.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildTitle(),
|
||||||
|
_buildSelectPhotosButton(),
|
||||||
|
_buildSelectedImageGrid(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
|
|
||||||
|
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||||
|
final SharedAlbum albumInfo;
|
||||||
|
|
||||||
|
const SelectAdditionalUserForSharingPage({Key? key, required this.albumInfo}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||||
|
final sharedUsersList = useState<Set<UserInfo>>({});
|
||||||
|
|
||||||
|
_addNewUsersHandler() {
|
||||||
|
AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTileIcon(UserInfo user) {
|
||||||
|
if (sharedUsersList.value.contains(user)) {
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
size: 25,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: const AssetImage('assets/immich-logo-no-outline.png'),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildUserList(List<UserInfo> users) {
|
||||||
|
List<Widget> usersChip = [];
|
||||||
|
|
||||||
|
for (var user in sharedUsersList.value) {
|
||||||
|
usersChip.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: Chip(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15),
|
||||||
|
label: Text(
|
||||||
|
user.email,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
children: [...usersChip],
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
'Suggestions',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemBuilder: ((context, index) {
|
||||||
|
return ListTile(
|
||||||
|
leading: _buildTileIcon(users[index]),
|
||||||
|
title: Text(
|
||||||
|
users[index].email,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (sharedUsersList.value.contains(users[index])) {
|
||||||
|
sharedUsersList.value =
|
||||||
|
sharedUsersList.value.where((selectedUser) => selectedUser.id != users[index].id).toSet();
|
||||||
|
} else {
|
||||||
|
sharedUsersList.value = {...sharedUsersList.value, users[index]};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
itemCount: users.length,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Invite to album',
|
||||||
|
style: TextStyle(color: Colors.black),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
AutoRouter.of(context).pop(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: sharedUsersList.value.isEmpty ? null : _addNewUsersHandler,
|
||||||
|
child: const Text(
|
||||||
|
"Add",
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: suggestedShareUsers.when(
|
||||||
|
data: (users) {
|
||||||
|
for (var sharedUsers in albumInfo.sharedUsers) {
|
||||||
|
users.removeWhere((u) => u.id == sharedUsers.sharedUserId || u.id == albumInfo.ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildUserList(users);
|
||||||
|
},
|
||||||
|
error: (e, _) => Text("Error loading suggested users $e"),
|
||||||
|
loading: () => const Center(
|
||||||
|
child: ImmichLoadingIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
|
|
||||||
|
class SelectUserForSharingPage extends HookConsumerWidget {
|
||||||
|
const SelectUserForSharingPage({Key? key}) : super(key: key);
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final sharedUsersList = useState<Set<UserInfo>>({});
|
||||||
|
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||||
|
|
||||||
|
_createSharedAlbum() async {
|
||||||
|
var isSuccess = await SharedAlbumService().createSharedAlbum(
|
||||||
|
ref.watch(albumTitleProvider),
|
||||||
|
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
|
||||||
|
sharedUsersList.value.map((userInfo) => userInfo.id).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||||
|
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||||
|
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||||
|
|
||||||
|
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album')));
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTileIcon(UserInfo user) {
|
||||||
|
if (sharedUsersList.value.contains(user)) {
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
size: 25,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: const AssetImage('assets/immich-logo-no-outline.png'),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildUserList(List<UserInfo> users) {
|
||||||
|
List<Widget> usersChip = [];
|
||||||
|
|
||||||
|
for (var user in sharedUsersList.value) {
|
||||||
|
usersChip.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: Chip(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15),
|
||||||
|
label: Text(
|
||||||
|
user.email,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
children: [...usersChip],
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
'Suggestions',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemBuilder: ((context, index) {
|
||||||
|
return ListTile(
|
||||||
|
leading: _buildTileIcon(users[index]),
|
||||||
|
title: Text(
|
||||||
|
users[index].email,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (sharedUsersList.value.contains(users[index])) {
|
||||||
|
sharedUsersList.value =
|
||||||
|
sharedUsersList.value.where((selectedUser) => selectedUser.id != users[index].id).toSet();
|
||||||
|
} else {
|
||||||
|
sharedUsersList.value = {...sharedUsersList.value, users[index]};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
itemCount: users.length,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Invite to album',
|
||||||
|
style: TextStyle(color: Colors.black),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
AutoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
|
||||||
|
child: const Text(
|
||||||
|
"Create Album",
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: suggestedShareUsers.when(
|
||||||
|
data: (users) {
|
||||||
|
return _buildUserList(users);
|
||||||
|
},
|
||||||
|
error: (e, _) => Text("Error loading suggested users $e"),
|
||||||
|
loading: () => const Center(
|
||||||
|
child: ImmichLoadingIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
mobile/lib/modules/sharing/views/sharing_page.dart
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/ui/sharing_sliver_appbar.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:transparent_image/transparent_image.dart';
|
||||||
|
|
||||||
|
class SharingPage extends HookConsumerWidget {
|
||||||
|
const SharingPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
var box = Hive.box(userInfoBox);
|
||||||
|
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||||
|
final List<SharedAlbum> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
_buildAlbumList() {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(BuildContext context, int index) {
|
||||||
|
String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId != null
|
||||||
|
? "$thumbnailRequestUrl/${sharedAlbums[index].albumThumbnailAssetId}"
|
||||||
|
: "https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60";
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: FadeInImage(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: MemoryImage(kTransparentImage),
|
||||||
|
image: NetworkImage(
|
||||||
|
thumbnailUrl,
|
||||||
|
headers: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
|
),
|
||||||
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
|
fadeOutDuration: const Duration(milliseconds: 200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
sharedAlbums[index].albumName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey.shade800),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
AutoRouter.of(context).push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: sharedAlbums.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildEmptyListIndication() {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10), // if you need this
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Colors.black12,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 5.0, bottom: 5),
|
||||||
|
child: Icon(
|
||||||
|
Icons.offline_share_outlined,
|
||||||
|
size: 50,
|
||||||
|
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
'EMPTY LIST',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
'Create shared albums to share photos and videos with people in your network.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
const SharingSliverAppBar(),
|
||||||
|
const SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
"Shared albums",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sharedAlbums.isNotEmpty ? _buildAlbumList() : _buildEmptyListIndication()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||||
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/views/album_viewer_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/views/asset_selection_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/views/create_shared_album_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/views/select_additional_user_for_sharing_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/views/sharing_page.dart';
|
||||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
|
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
|
|
||||||
@@ -22,13 +33,33 @@ part 'router.gr.dart';
|
|||||||
guards: [AuthGuard],
|
guards: [AuthGuard],
|
||||||
children: [
|
children: [
|
||||||
AutoRoute(page: HomePage, guards: [AuthGuard]),
|
AutoRoute(page: HomePage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: SearchPage, guards: [AuthGuard])
|
AutoRoute(page: SearchPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: SharingPage, guards: [AuthGuard])
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: CreateSharedAlbumPage, guards: [AuthGuard]),
|
||||||
|
CustomRoute<AssetSelectionPageResult?>(
|
||||||
|
page: AssetSelectionPage,
|
||||||
|
guards: [AuthGuard],
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
),
|
||||||
|
CustomRoute<List<String>>(
|
||||||
|
page: SelectUserForSharingPage,
|
||||||
|
guards: [AuthGuard],
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
),
|
||||||
|
AutoRoute(page: AlbumViewerPage, guards: [AuthGuard]),
|
||||||
|
CustomRoute<List<String>?>(
|
||||||
|
page: SelectAdditionalUserForSharingPage,
|
||||||
|
guards: [AuthGuard],
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
),
|
||||||
|
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
|||||||
@@ -57,6 +57,52 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
routeData: routeData,
|
routeData: routeData,
|
||||||
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
|
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
|
||||||
},
|
},
|
||||||
|
CreateSharedAlbumRoute.name: (routeData) {
|
||||||
|
return MaterialPageX<dynamic>(
|
||||||
|
routeData: routeData, child: const CreateSharedAlbumPage());
|
||||||
|
},
|
||||||
|
AssetSelectionRoute.name: (routeData) {
|
||||||
|
return CustomPage<AssetSelectionPageResult?>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: const AssetSelectionPage(),
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
opaque: true,
|
||||||
|
barrierDismissible: false);
|
||||||
|
},
|
||||||
|
SelectUserForSharingRoute.name: (routeData) {
|
||||||
|
return CustomPage<List<String>>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: const SelectUserForSharingPage(),
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
opaque: true,
|
||||||
|
barrierDismissible: false);
|
||||||
|
},
|
||||||
|
AlbumViewerRoute.name: (routeData) {
|
||||||
|
final args = routeData.argsAs<AlbumViewerRouteArgs>();
|
||||||
|
return MaterialPageX<dynamic>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: AlbumViewerPage(key: args.key, albumId: args.albumId));
|
||||||
|
},
|
||||||
|
SelectAdditionalUserForSharingRoute.name: (routeData) {
|
||||||
|
final args = routeData.argsAs<SelectAdditionalUserForSharingRouteArgs>();
|
||||||
|
return CustomPage<List<String>?>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: SelectAdditionalUserForSharingPage(
|
||||||
|
key: args.key, albumInfo: args.albumInfo),
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
opaque: true,
|
||||||
|
barrierDismissible: false);
|
||||||
|
},
|
||||||
|
BackupAlbumSelectionRoute.name: (routeData) {
|
||||||
|
return MaterialPageX<dynamic>(
|
||||||
|
routeData: routeData, child: const BackupAlbumSelectionPage());
|
||||||
|
},
|
||||||
|
AlbumPreviewRoute.name: (routeData) {
|
||||||
|
final args = routeData.argsAs<AlbumPreviewRouteArgs>();
|
||||||
|
return MaterialPageX<dynamic>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: AlbumPreviewPage(key: args.key, album: args.album));
|
||||||
|
},
|
||||||
HomeRoute.name: (routeData) {
|
HomeRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const HomePage());
|
routeData: routeData, child: const HomePage());
|
||||||
@@ -66,6 +112,10 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
orElse: () => const SearchRouteArgs());
|
orElse: () => const SearchRouteArgs());
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: SearchPage(key: args.key));
|
routeData: routeData, child: SearchPage(key: args.key));
|
||||||
|
},
|
||||||
|
SharingRoute.name: (routeData) {
|
||||||
|
return MaterialPageX<dynamic>(
|
||||||
|
routeData: routeData, child: const SharingPage());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +135,10 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
RouteConfig(SearchRoute.name,
|
RouteConfig(SearchRoute.name,
|
||||||
path: 'search-page',
|
path: 'search-page',
|
||||||
parent: TabControllerRoute.name,
|
parent: TabControllerRoute.name,
|
||||||
|
guards: [authGuard]),
|
||||||
|
RouteConfig(SharingRoute.name,
|
||||||
|
path: 'sharing-page',
|
||||||
|
parent: TabControllerRoute.name,
|
||||||
guards: [authGuard])
|
guards: [authGuard])
|
||||||
]),
|
]),
|
||||||
RouteConfig(ImageViewerRoute.name,
|
RouteConfig(ImageViewerRoute.name,
|
||||||
@@ -94,7 +148,22 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
RouteConfig(BackupControllerRoute.name,
|
RouteConfig(BackupControllerRoute.name,
|
||||||
path: '/backup-controller-page', guards: [authGuard]),
|
path: '/backup-controller-page', guards: [authGuard]),
|
||||||
RouteConfig(SearchResultRoute.name,
|
RouteConfig(SearchResultRoute.name,
|
||||||
path: '/search-result-page', guards: [authGuard])
|
path: '/search-result-page', guards: [authGuard]),
|
||||||
|
RouteConfig(CreateSharedAlbumRoute.name,
|
||||||
|
path: '/create-shared-album-page', guards: [authGuard]),
|
||||||
|
RouteConfig(AssetSelectionRoute.name,
|
||||||
|
path: '/asset-selection-page', guards: [authGuard]),
|
||||||
|
RouteConfig(SelectUserForSharingRoute.name,
|
||||||
|
path: '/select-user-for-sharing-page', guards: [authGuard]),
|
||||||
|
RouteConfig(AlbumViewerRoute.name,
|
||||||
|
path: '/album-viewer-page', guards: [authGuard]),
|
||||||
|
RouteConfig(SelectAdditionalUserForSharingRoute.name,
|
||||||
|
path: '/select-additional-user-for-sharing-page',
|
||||||
|
guards: [authGuard]),
|
||||||
|
RouteConfig(BackupAlbumSelectionRoute.name,
|
||||||
|
path: '/backup-album-selection-page', guards: [authGuard]),
|
||||||
|
RouteConfig(AlbumPreviewRoute.name,
|
||||||
|
path: '/album-preview-page', guards: [authGuard])
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +292,120 @@ class SearchResultRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [CreateSharedAlbumPage]
|
||||||
|
class CreateSharedAlbumRoute extends PageRouteInfo<void> {
|
||||||
|
const CreateSharedAlbumRoute()
|
||||||
|
: super(CreateSharedAlbumRoute.name, path: '/create-shared-album-page');
|
||||||
|
|
||||||
|
static const String name = 'CreateSharedAlbumRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [AssetSelectionPage]
|
||||||
|
class AssetSelectionRoute extends PageRouteInfo<void> {
|
||||||
|
const AssetSelectionRoute()
|
||||||
|
: super(AssetSelectionRoute.name, path: '/asset-selection-page');
|
||||||
|
|
||||||
|
static const String name = 'AssetSelectionRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [SelectUserForSharingPage]
|
||||||
|
class SelectUserForSharingRoute extends PageRouteInfo<void> {
|
||||||
|
const SelectUserForSharingRoute()
|
||||||
|
: super(SelectUserForSharingRoute.name,
|
||||||
|
path: '/select-user-for-sharing-page');
|
||||||
|
|
||||||
|
static const String name = 'SelectUserForSharingRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [AlbumViewerPage]
|
||||||
|
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
||||||
|
AlbumViewerRoute({Key? key, required String albumId})
|
||||||
|
: super(AlbumViewerRoute.name,
|
||||||
|
path: '/album-viewer-page',
|
||||||
|
args: AlbumViewerRouteArgs(key: key, albumId: albumId));
|
||||||
|
|
||||||
|
static const String name = 'AlbumViewerRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumViewerRouteArgs {
|
||||||
|
const AlbumViewerRouteArgs({this.key, required this.albumId});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final String albumId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AlbumViewerRouteArgs{key: $key, albumId: $albumId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [SelectAdditionalUserForSharingPage]
|
||||||
|
class SelectAdditionalUserForSharingRoute
|
||||||
|
extends PageRouteInfo<SelectAdditionalUserForSharingRouteArgs> {
|
||||||
|
SelectAdditionalUserForSharingRoute(
|
||||||
|
{Key? key, required SharedAlbum albumInfo})
|
||||||
|
: super(SelectAdditionalUserForSharingRoute.name,
|
||||||
|
path: '/select-additional-user-for-sharing-page',
|
||||||
|
args: SelectAdditionalUserForSharingRouteArgs(
|
||||||
|
key: key, albumInfo: albumInfo));
|
||||||
|
|
||||||
|
static const String name = 'SelectAdditionalUserForSharingRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectAdditionalUserForSharingRouteArgs {
|
||||||
|
const SelectAdditionalUserForSharingRouteArgs(
|
||||||
|
{this.key, required this.albumInfo});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final SharedAlbum albumInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SelectAdditionalUserForSharingRouteArgs{key: $key, albumInfo: $albumInfo}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [BackupAlbumSelectionPage]
|
||||||
|
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
|
||||||
|
const BackupAlbumSelectionRoute()
|
||||||
|
: super(BackupAlbumSelectionRoute.name,
|
||||||
|
path: '/backup-album-selection-page');
|
||||||
|
|
||||||
|
static const String name = 'BackupAlbumSelectionRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [AlbumPreviewPage]
|
||||||
|
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
|
||||||
|
AlbumPreviewRoute({Key? key, required AssetPathEntity album})
|
||||||
|
: super(AlbumPreviewRoute.name,
|
||||||
|
path: '/album-preview-page',
|
||||||
|
args: AlbumPreviewRouteArgs(key: key, album: album));
|
||||||
|
|
||||||
|
static const String name = 'AlbumPreviewRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumPreviewRouteArgs {
|
||||||
|
const AlbumPreviewRouteArgs({this.key, required this.album});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final AssetPathEntity album;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AlbumPreviewRouteArgs{key: $key, album: $album}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
class HomeRoute extends PageRouteInfo<void> {
|
||||||
@@ -251,3 +434,11 @@ class SearchRouteArgs {
|
|||||||
return 'SearchRouteArgs{key: $key}';
|
return 'SearchRouteArgs{key: $key}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [SharingPage]
|
||||||
|
class SharingRoute extends PageRouteInfo<void> {
|
||||||
|
const SharingRoute() : super(SharingRoute.name, path: 'sharing-page');
|
||||||
|
|
||||||
|
static const String name = 'SharingRoute';
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
|
|
||||||
class TabNavigationObserver extends AutoRouterObserver {
|
class TabNavigationObserver extends AutoRouterObserver {
|
||||||
@@ -21,7 +22,8 @@ class TabNavigationObserver extends AutoRouterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
|
Future<void> didChangeTabRoute(
|
||||||
|
TabPageRoute route, TabPageRoute previousRoute) async {
|
||||||
// Perform tasks on re-visit to SearchRoute
|
// Perform tasks on re-visit to SearchRoute
|
||||||
if (route.name == 'SearchRoute') {
|
if (route.name == 'SearchRoute') {
|
||||||
// Refresh Location State
|
// Refresh Location State
|
||||||
@@ -29,6 +31,10 @@ class TabNavigationObserver extends AutoRouterObserver {
|
|||||||
ref.refresh(getCuratedObjectProvider);
|
ref.refresh(getCuratedObjectProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.name == 'SharingRoute') {
|
||||||
|
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
|
||||||
|
|
||||||
enum BackUpProgressEnum { idle, inProgress, done }
|
|
||||||
|
|
||||||
class BackUpState {
|
|
||||||
final BackUpProgressEnum backupProgress;
|
|
||||||
final int totalAssetCount;
|
|
||||||
final int assetOnDatabase;
|
|
||||||
final int backingUpAssetCount;
|
|
||||||
final double progressInPercentage;
|
|
||||||
final CancelToken cancelToken;
|
|
||||||
final ServerInfo serverInfo;
|
|
||||||
|
|
||||||
BackUpState({
|
|
||||||
required this.backupProgress,
|
|
||||||
required this.totalAssetCount,
|
|
||||||
required this.assetOnDatabase,
|
|
||||||
required this.backingUpAssetCount,
|
|
||||||
required this.progressInPercentage,
|
|
||||||
required this.cancelToken,
|
|
||||||
required this.serverInfo,
|
|
||||||
});
|
|
||||||
|
|
||||||
BackUpState copyWith({
|
|
||||||
BackUpProgressEnum? backupProgress,
|
|
||||||
int? totalAssetCount,
|
|
||||||
int? assetOnDatabase,
|
|
||||||
int? backingUpAssetCount,
|
|
||||||
double? progressInPercentage,
|
|
||||||
CancelToken? cancelToken,
|
|
||||||
ServerInfo? serverInfo,
|
|
||||||
}) {
|
|
||||||
return BackUpState(
|
|
||||||
backupProgress: backupProgress ?? this.backupProgress,
|
|
||||||
totalAssetCount: totalAssetCount ?? this.totalAssetCount,
|
|
||||||
assetOnDatabase: assetOnDatabase ?? this.assetOnDatabase,
|
|
||||||
backingUpAssetCount: backingUpAssetCount ?? this.backingUpAssetCount,
|
|
||||||
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
|
||||||
cancelToken: cancelToken ?? this.cancelToken,
|
|
||||||
serverInfo: serverInfo ?? this.serverInfo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'BackUpState(backupProgress: $backupProgress, totalAssetCount: $totalAssetCount, assetOnDatabase: $assetOnDatabase, backingUpAssetCount: $backingUpAssetCount, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
|
|
||||||
return other is BackUpState &&
|
|
||||||
other.backupProgress == backupProgress &&
|
|
||||||
other.totalAssetCount == totalAssetCount &&
|
|
||||||
other.assetOnDatabase == assetOnDatabase &&
|
|
||||||
other.backingUpAssetCount == backingUpAssetCount &&
|
|
||||||
other.progressInPercentage == progressInPercentage &&
|
|
||||||
other.cancelToken == cancelToken &&
|
|
||||||
other.serverInfo == serverInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode {
|
|
||||||
return backupProgress.hashCode ^
|
|
||||||
totalAssetCount.hashCode ^
|
|
||||||
assetOnDatabase.hashCode ^
|
|
||||||
backingUpAssetCount.hashCode ^
|
|
||||||
progressInPercentage.hashCode ^
|
|
||||||
cancelToken.hashCode ^
|
|
||||||
serverInfo.hashCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
mobile/lib/shared/models/user_info.model.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class UserInfo {
|
||||||
|
final String id;
|
||||||
|
final String email;
|
||||||
|
final String createdAt;
|
||||||
|
|
||||||
|
UserInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.email,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
UserInfo copyWith({
|
||||||
|
String? id,
|
||||||
|
String? email,
|
||||||
|
String? createdAt,
|
||||||
|
}) {
|
||||||
|
return UserInfo(
|
||||||
|
id: id ?? this.id,
|
||||||
|
email: email ?? this.email,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'id': id});
|
||||||
|
result.addAll({'email': email});
|
||||||
|
result.addAll({'createdAt': createdAt});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UserInfo.fromMap(Map<String, dynamic> map) {
|
||||||
|
return UserInfo(
|
||||||
|
id: map['id'] ?? '',
|
||||||
|
email: map['email'] ?? '',
|
||||||
|
createdAt: map['createdAt'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory UserInfo.fromJson(String source) => UserInfo.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is UserInfo && other.id == id && other.email == email && other.createdAt == createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode ^ email.hashCode ^ createdAt.hashCode;
|
||||||
|
}
|
||||||
@@ -72,3 +72,11 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
|
|||||||
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||||
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
|
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final assetGroupByMonthYearProvider = StateProvider((ref) {
|
||||||
|
var assets = ref.watch(assetProvider);
|
||||||
|
|
||||||
|
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||||
|
|
||||||
|
return assets.groupListsBy((element) => DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)));
|
||||||
|
});
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
|
||||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
|
||||||
import 'package:immich_mobile/shared/services/backup.service.dart';
|
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
|
||||||
BackupNotifier({this.ref})
|
|
||||||
: super(
|
|
||||||
BackUpState(
|
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
|
||||||
backingUpAssetCount: 0,
|
|
||||||
assetOnDatabase: 0,
|
|
||||||
totalAssetCount: 0,
|
|
||||||
progressInPercentage: 0,
|
|
||||||
cancelToken: CancelToken(),
|
|
||||||
serverInfo: ServerInfo(
|
|
||||||
diskAvailable: "0",
|
|
||||||
diskAvailableRaw: 0,
|
|
||||||
diskSize: "0",
|
|
||||||
diskSizeRaw: 0,
|
|
||||||
diskUsagePercentage: 0.0,
|
|
||||||
diskUse: "0",
|
|
||||||
diskUseRaw: 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ref? ref;
|
|
||||||
final BackupService _backupService = BackupService();
|
|
||||||
final ServerInfoService _serverInfoService = ServerInfoService();
|
|
||||||
final StreamController _onAssetBackupStreamCtrl =
|
|
||||||
StreamController.broadcast();
|
|
||||||
|
|
||||||
void getBackupInfo() async {
|
|
||||||
_updateServerInfo();
|
|
||||||
|
|
||||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
|
|
||||||
onlyAll: true, type: RequestType.common);
|
|
||||||
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
|
|
||||||
|
|
||||||
if (list.isEmpty) {
|
|
||||||
debugPrint("No Asset On Device");
|
|
||||||
state = state.copyWith(
|
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
|
||||||
totalAssetCount: 0,
|
|
||||||
assetOnDatabase: didBackupAsset.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalAsset = list[0].assetCount;
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
void startBackupProcess() async {
|
|
||||||
_updateServerInfo();
|
|
||||||
|
|
||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
|
||||||
|
|
||||||
var authResult = await PhotoManager.requestPermissionExtend();
|
|
||||||
if (authResult.isAuth) {
|
|
||||||
await PhotoManager.clearFileCache();
|
|
||||||
// await PhotoManager.presentLimited();
|
|
||||||
// Gather assets info
|
|
||||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
|
|
||||||
hasAll: true, onlyAll: true, type: RequestType.common);
|
|
||||||
|
|
||||||
// Get device assets info from database
|
|
||||||
// Compare and find different assets that has not been backing up
|
|
||||||
// Backup those assets
|
|
||||||
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
|
|
||||||
|
|
||||||
if (list.isEmpty) {
|
|
||||||
debugPrint("No Asset On Device - Abort Backup Process");
|
|
||||||
state = state.copyWith(
|
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
|
||||||
totalAssetCount: 0,
|
|
||||||
assetOnDatabase: backupAsset.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalAsset = list[0].assetCount;
|
|
||||||
List<AssetEntity> currentAssets =
|
|
||||||
await list[0].getAssetListRange(start: 0, end: totalAsset);
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
|
||||||
// Remove item that has already been backed up
|
|
||||||
for (var backupAssetId in backupAsset) {
|
|
||||||
currentAssets.removeWhere((e) => e.id == backupAssetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentAssets.isEmpty) {
|
|
||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(backingUpAssetCount: currentAssets.length);
|
|
||||||
|
|
||||||
// Perform Backup
|
|
||||||
state = state.copyWith(cancelToken: CancelToken());
|
|
||||||
_backupService.backupAsset(currentAssets, state.cancelToken,
|
|
||||||
_onAssetUploaded, _onUploadProgress);
|
|
||||||
} else {
|
|
||||||
PhotoManager.openSetting();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void cancelBackup() {
|
|
||||||
state.cancelToken.cancel('Cancel Backup');
|
|
||||||
state = state.copyWith(
|
|
||||||
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
|
||||||
state = state.copyWith(
|
|
||||||
backingUpAssetCount: state.backingUpAssetCount - 1,
|
|
||||||
assetOnDatabase: state.assetOnDatabase + 1);
|
|
||||||
|
|
||||||
if (state.backingUpAssetCount == 0) {
|
|
||||||
state = state.copyWith(
|
|
||||||
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateServerInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onUploadProgress(int sent, int total) {
|
|
||||||
state = state.copyWith(
|
|
||||||
progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateServerInfo() async {
|
|
||||||
var serverInfo = await _serverInfoService.getServerInfo();
|
|
||||||
|
|
||||||
// Update server info
|
|
||||||
state = state.copyWith(
|
|
||||||
serverInfo: ServerInfo(
|
|
||||||
diskSize: serverInfo.diskSize,
|
|
||||||
diskUse: serverInfo.diskUse,
|
|
||||||
diskAvailable: serverInfo.diskAvailable,
|
|
||||||
diskSizeRaw: serverInfo.diskSizeRaw,
|
|
||||||
diskUseRaw: serverInfo.diskUseRaw,
|
|
||||||
diskAvailableRaw: serverInfo.diskAvailableRaw,
|
|
||||||
diskUsagePercentage: serverInfo.diskUsagePercentage,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void resumeBackup() {
|
|
||||||
var authState = ref?.read(authenticationProvider);
|
|
||||||
|
|
||||||
// Check if user is login
|
|
||||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
|
||||||
|
|
||||||
// User has been logged out return
|
|
||||||
if (authState != null) {
|
|
||||||
if (accessKey == null || !authState.isAuthenticated) {
|
|
||||||
debugPrint("[resumeBackup] not authenticated - abort");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this device is enable backup by the user
|
|
||||||
if ((authState.deviceInfo.deviceId == authState.deviceId) &&
|
|
||||||
authState.deviceInfo.isAutoBackup) {
|
|
||||||
// check if backup is alreayd in process - then return
|
|
||||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
|
||||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run backup
|
|
||||||
debugPrint("[resumeBackup] Start back up");
|
|
||||||
startBackupProcess();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final backupProvider =
|
|
||||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
|
||||||
return BackupNotifier(ref: ref);
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||||
import 'package:socket_io_client/socket_io_client.dart';
|
import 'package:socket_io_client/socket_io_client.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,10 @@ class NetworkService {
|
|||||||
return res;
|
return res;
|
||||||
} on DioError catch (e) {
|
} on DioError catch (e) {
|
||||||
debugPrint("DioError: ${e.response}");
|
debugPrint("DioError: ${e.response}");
|
||||||
return false;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("ERROR BackupService: $e");
|
debugPrint("ERROR BackupService: $e");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
mobile/lib/shared/services/user.service.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
|
|
||||||
|
class UserService {
|
||||||
|
final NetworkService _networkService = NetworkService();
|
||||||
|
|
||||||
|
Future<List<UserInfo>> getAllUsersInfo() async {
|
||||||
|
try {
|
||||||
|
Response res = await _networkService.getRequest(url: 'user');
|
||||||
|
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
List<UserInfo> result = List.from(decodedData.map((e) => UserInfo.fromMap(e)));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error getAllUsersInfo ${e.toString()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
mobile/lib/shared/ui/immich_loading_indicator.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||||
|
|
||||||
|
class ImmichLoadingIndicator extends StatelessWidget {
|
||||||
|
const ImmichLoadingIndicator({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const SpinKitDancingSquare(
|
||||||
|
color: Colors.white,
|
||||||
|
size: 30.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ImmichSliverPersistentAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
final double minHeight;
|
||||||
|
final double maxHeight;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
ImmichSliverPersistentAppBarDelegate({
|
||||||
|
required this.minHeight,
|
||||||
|
required this.maxHeight,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minExtent => minHeight;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => max(maxHeight, minHeight);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||||
|
return SizedBox.expand(child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(ImmichSliverPersistentAppBarDelegate oldDelegate) {
|
||||||
|
return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
mobile/lib/shared/views/immich_loading_overlay.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
|
|
||||||
|
class ImmichLoadingOverlay extends StatelessWidget {
|
||||||
|
const ImmichLoadingOverlay({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
|
||||||
|
builder: (context, shouldShow, child) {
|
||||||
|
if (shouldShow) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
body: Center(
|
||||||
|
child: ImmichLoadingIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImmichLoadingOverlayController {
|
||||||
|
static final ImmichLoadingOverlayController appLoader = ImmichLoadingOverlayController();
|
||||||
|
ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
|
||||||
|
ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');
|
||||||
|
|
||||||
|
void show() {
|
||||||
|
loaderShowingNotifier.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void hide() {
|
||||||
|
loaderShowingNotifier.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||