Compare commits
31 Commits
v1.4.0+7-d
...
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 | ||
|
|
c7dff229db | ||
|
|
8e80825b4f | ||
|
|
a1481c1113 | ||
|
|
3bdcdef198 | ||
|
|
b69f6e0df7 | ||
|
|
be2794a372 | ||
|
|
2ff25b49f4 | ||
|
|
135d72d4cd | ||
|
|
90ef64efa3 | ||
|
|
60df387459 | ||
|
|
fc1acf6f01 |
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: alextran1502
|
||||
custom: https://www.buymeacoffee.com/altran1502?new=1
|
||||
64
.github/workflows/build_push_docker_latest.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Build and Push Docker Image - Latest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
|
||||
build_and_push_server_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-server:latest
|
||||
|
||||
build_and_push_microservice_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Microservices
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./microservices
|
||||
file: ./microservices/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-microservices:latest
|
||||
42
.github/workflows/build_push_server_latest.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Build Server - Latest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
buildandpush:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
# https://github.com/docker/setup-qemu-action#usage
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
# https://github.com/docker/login-action#docker-hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-server:latest
|
||||
75
.github/workflows/build_push_server_release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build Server - Release
|
||||
name: Build and push Docker image - Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,35 +6,78 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
buildandpush:
|
||||
build_and_push_server_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
# https://github.com/docker/setup-qemu-action#usage
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Get Previous tag'
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
# https://github.com/docker/login-action#docker-hub
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
|
||||
- name: Build and push immich-server release
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-server:${{github.ref_name}}
|
||||
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
|
||||
|
||||
build_and_push_microservice_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Get Previous tag'
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-microservices release
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: ./microservices
|
||||
file: ./microservices/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
|
||||
5
Makefile
@@ -8,4 +8,7 @@ dev-scale:
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
|
||||
|
||||
prod:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
prod-scale:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --scale immich_microservices=3 --remove-orphans
|
||||
@@ -1,13 +1,17 @@
|
||||
# Deployment checklist for iOS/Android/Server
|
||||
|
||||
[] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
|
||||
[] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.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.gpu.yml](/docker/docker-compose.gpu.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 [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
|
||||
|
||||
[] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
|
||||
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
|
||||
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
|
||||
|
||||
All of the version should be the same.
|
||||
135
README.md
@@ -32,10 +32,12 @@ Loading ~4000 images/videos
|
||||
## Screenshots
|
||||
|
||||
<p align="left">
|
||||
<img src="design/nsc1.png" width="150" title="Login With Custom URL">
|
||||
<img src="design/nsc2.png" width="150" title="Backup Setting Info">
|
||||
<img src="design/nsc3.png" width="150" title="Multiple select">
|
||||
<img src="design/nsc4.jpeg" width="150" title="Curated Search Info">
|
||||
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
|
||||
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
|
||||
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
|
||||
<img src="design/home-screen.jpeg" width="150" title="Home Screen">
|
||||
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
|
||||
<img src="design/shared-albums.png" width="150" title="Shared Albums">
|
||||
<img src="design/nsc6.png" width="150" title="EXIF Info">
|
||||
|
||||
</p>
|
||||
@@ -48,33 +50,48 @@ This project is under heavy development, there will be continous functions, feat
|
||||
|
||||
# Features
|
||||
|
||||
- Upload and view assets(videos/images).
|
||||
- Upload and view assets (videos/images).
|
||||
- Auto Backup.
|
||||
- Download asset to local device.
|
||||
- Multi-user supported.
|
||||
- Quick navigation with drag scroll bar.
|
||||
- Auto Backup.
|
||||
- Support HEIC/HEIF Backup.
|
||||
- Extract and display EXIF info.
|
||||
- Real-time render from multi-device upload event.
|
||||
- Image Tagging/Classification based on ImageNet dataset
|
||||
- Object detection based on COCO SSD.
|
||||
- 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] Reserve 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 curated places 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
|
||||
2. PostgreSQL
|
||||
3. Redis
|
||||
4. Nginx
|
||||
5. TensorFlow
|
||||
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
```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`
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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">
|
||||
<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/>
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -135,14 +200,22 @@ Cheer! 🎉
|
||||
|
||||
# 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
|
||||
more /proc/cpuinfo | grep flags
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
`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
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE_NAME=
|
||||
DB_DATABASE_NAME=immich
|
||||
|
||||
# Upload File Config
|
||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||
|
||||
# JWT SECRET
|
||||
JWT_SECRET=
|
||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||
|
||||
# MAPBOX
|
||||
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||
ENABLE_MAPBOX=
|
||||
ENABLE_MAPBOX=false
|
||||
MAPBOX_KEY=
|
||||
@@ -2,10 +2,10 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.3.2
|
||||
image: immich-server-dev:1.9.0
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: ../server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3000"
|
||||
@@ -24,10 +24,10 @@ services:
|
||||
- immich_network
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices-dev:1.3.2
|
||||
image: immich-microservices-dev:1.9.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3001"
|
||||
|
||||
@@ -2,10 +2,10 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.4.0
|
||||
image: immich-server-dev:1.9.0
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: ../server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3000"
|
||||
@@ -22,10 +22,10 @@ services:
|
||||
- immich_network
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices-dev:1.4.0
|
||||
image: immich-microservices-dev:1.9.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
@@ -2,17 +2,12 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server:1.4.0
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: ../server/Dockerfile
|
||||
image: altran1502/immich-server:v1.8.0_12-dev
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -22,19 +17,15 @@ services:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
restart: unless-stopped
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices:1.4.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
image: altran1502/immich-microservices:v1.8.0_12-dev
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -43,7 +34,7 @@ services:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
@@ -83,27 +74,6 @@ services:
|
||||
depends_on:
|
||||
- immich_server
|
||||
|
||||
# immich_tf_fastapi:
|
||||
# container_name: immich_tf_fastapi
|
||||
# image: tensor_flow_fastapi:1.0.0
|
||||
# restart: always
|
||||
# command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
|
||||
# build:
|
||||
# context: ../machine_learning
|
||||
# target: cpu
|
||||
# dockerfile: ../machine_learning/Dockerfile
|
||||
# volumes:
|
||||
# - ../machine_learning/app:/code/app
|
||||
# - ${UPLOAD_LOCATION}:/code/app/upload
|
||||
# ports:
|
||||
# - 2285:8000
|
||||
# expose:
|
||||
# - "8000"
|
||||
# depends_on:
|
||||
# - database
|
||||
# networks:
|
||||
# - immich_network
|
||||
|
||||
networks:
|
||||
immich_network:
|
||||
volumes:
|
||||
|
||||
@@ -10,11 +10,22 @@ map $http_upgrade $connection_upgrade {
|
||||
|
||||
server {
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gunzip on;
|
||||
|
||||
client_max_body_size 50000M;
|
||||
|
||||
listen 80;
|
||||
access_log off;
|
||||
|
||||
location / {
|
||||
|
||||
# Compression
|
||||
gzip_static on;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 2;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 24k;
|
||||
|
||||
@@ -39,6 +39,7 @@ export class ImageClassifierService {
|
||||
}
|
||||
}
|
||||
|
||||
tf.dispose(decodedImage);
|
||||
return tags;
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -29,6 +29,7 @@ export class ObjectDetectionService {
|
||||
}
|
||||
}
|
||||
|
||||
tf.dispose(decodedImage);
|
||||
return [...tags];
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -51,7 +51,7 @@ android {
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 20
|
||||
minSdkVersion 21
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -81,4 +81,5 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'com.android.support:multidex:1.0.3'
|
||||
}
|
||||
|
||||
@@ -20,4 +20,7 @@
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,2 @@
|
||||
* Accepting webp file format
|
||||
* Fixed backup stop when an asset is of wrong file type. The app will now skip that asset and try its best to perform the backup operation on the rest of the assets.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
* User can now download assets to local device
|
||||
* Increased the font size for curated image thumbnail information on the seach page
|
||||
@@ -0,0 +1 @@
|
||||
* Added inline font, remove google-font dependency in pubspec.
|
||||
|
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 |
@@ -1 +1 @@
|
||||
This is a client app for the self-hostable Immich Server (which can be found with the app's source repo). You will need to run/manage the server on your own in order to use the app.
|
||||
This is a client app for the self-hostable Immich Server
|
||||
|
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 |
BIN
mobile/fonts/SnowburstOne.ttf
Normal file
BIN
mobile/fonts/WorkSans-Italic.ttf
Normal file
BIN
mobile/fonts/WorkSans.ttf
Normal file
@@ -13,7 +13,7 @@ PODS:
|
||||
- Flutter
|
||||
- path_provider_ios (0.0.1):
|
||||
- Flutter
|
||||
- photo_manager (1.0.0):
|
||||
- photo_manager (2.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SAMKeychain (1.5.3)
|
||||
@@ -70,7 +70,7 @@ SPEC CHECKSUMS:
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
|
||||
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
|
||||
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
|
||||
@@ -1,66 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Immich</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Immich</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false />
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,11 +19,11 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.4.0"
|
||||
version_number: "1.9.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
)
|
||||
increment_build_number({
|
||||
build_number: 0
|
||||
})
|
||||
build_app(scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
xcargs: "-allowProvisioningUpdates")
|
||||
|
||||
@@ -3,9 +3,13 @@ const String userInfoBox = "immichBoxUserInfo"; // Box
|
||||
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
|
||||
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
||||
|
||||
// SERVER ENDPOINT
|
||||
// Server endpoint
|
||||
const String serverEndpointKey = 'immichBoxServerEndpoint';
|
||||
|
||||
// KEY
|
||||
const String hiveAllAsssetKey = "allAssets";
|
||||
const String hiveBackupProgressKey = "backupProgressAssets";
|
||||
// Login Info
|
||||
const String hiveLoginInfoBox = "immichLoginInfoBox";
|
||||
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,21 +2,28 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.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/tab_navigation_observer.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/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
import 'constants/hive_box.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
void main() async {
|
||||
await Hive.initFlutter();
|
||||
|
||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
|
||||
await Hive.openBox(userInfoBox);
|
||||
// Hive.registerAdapter(ImmichBackUpAssetAdapter());
|
||||
// Hive.deleteBoxFromDisk(hiveImmichBox);
|
||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
@@ -88,26 +95,33 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: 'Immich',
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
textTheme: GoogleFonts.workSansTextTheme(
|
||||
Theme.of(context).textTheme.apply(fontSizeFactor: 1.0),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFf6f8fe),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.indigo,
|
||||
elevation: 1,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
home: Stack(
|
||||
children: [
|
||||
MaterialApp.router(
|
||||
title: 'Immich',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
fontFamily: 'WorkSans',
|
||||
snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
|
||||
scaffoldBackgroundColor: immichBackgroundColor,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
foregroundColor: Colors.indigo,
|
||||
elevation: 1,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
),
|
||||
routeInformationParser: _immichRouter.defaultRouteParser(),
|
||||
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
||||
),
|
||||
const ImmichLoadingOverlay(),
|
||||
],
|
||||
),
|
||||
routeInformationParser: _immichRouter.defaultRouteParser(),
|
||||
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum DownloadAssetStatus { idle, loading, success, error }
|
||||
|
||||
class ImageViewerPageState {
|
||||
final bool isBottomSheetEnable;
|
||||
// enum
|
||||
final DownloadAssetStatus downloadAssetStatus;
|
||||
|
||||
ImageViewerPageState({
|
||||
required this.isBottomSheetEnable,
|
||||
required this.downloadAssetStatus,
|
||||
});
|
||||
|
||||
ImageViewerPageState copyWith({
|
||||
bool? isBottomSheetEnable,
|
||||
DownloadAssetStatus? downloadAssetStatus,
|
||||
}) {
|
||||
return ImageViewerPageState(
|
||||
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
|
||||
downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'isBottomSheetEnable': isBottomSheetEnable,
|
||||
};
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
|
||||
return ImageViewerPageState(
|
||||
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
|
||||
downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,15 +37,15 @@ class ImageViewerPageState {
|
||||
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
|
||||
String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
|
||||
return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isBottomSheetEnable.hashCode;
|
||||
int get hashCode => downloadAssetStatus.hashCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class RequestDownloadAssetInfo {
|
||||
final String assetId;
|
||||
final String deviceId;
|
||||
|
||||
RequestDownloadAssetInfo(this.assetId, this.deviceId);
|
||||
}
|
||||
@@ -1,21 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
|
||||
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
final ImageViewerService _imageViewerService = ImageViewerService();
|
||||
|
||||
void toggleBottomSheet() {
|
||||
bool isBottomSheetEnable = state.isBottomSheetEnable;
|
||||
ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
|
||||
|
||||
if (isBottomSheetEnable) {
|
||||
state.copyWith(isBottomSheetEnable: false);
|
||||
void downloadAsset(ImmichAsset asset, BuildContext context) async {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
|
||||
|
||||
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
|
||||
|
||||
if (isSuccess) {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Download Success",
|
||||
toastType: ToastType.success,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
} else {
|
||||
state.copyWith(isBottomSheetEnable: true);
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Download Error",
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
|
||||
}
|
||||
}
|
||||
|
||||
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
|
||||
((ref) => ImageViewerPageStateNotifier()));
|
||||
final imageViewerStateProvider =
|
||||
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class ImageViewerService {
|
||||
Future<bool> downloadAssetToDevice(ImmichAsset asset) async {
|
||||
try {
|
||||
String fileName = p.basename(asset.originalPath);
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
Uri filePath =
|
||||
Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
|
||||
|
||||
var res = await http.get(
|
||||
filePath,
|
||||
headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"},
|
||||
);
|
||||
|
||||
final AssetEntity? entity;
|
||||
|
||||
if (asset.type == 'IMAGE') {
|
||||
entity = await PhotoManager.editor.saveImage(
|
||||
res.bodyBytes,
|
||||
title: p.basename(asset.originalPath),
|
||||
);
|
||||
} else {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
File tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
|
||||
}
|
||||
|
||||
if (entity != null) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error saving file $e");
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
|
||||
class DownloadLoadingIndicator extends StatelessWidget {
|
||||
const DownloadLoadingIndicator({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const SpinKitDancingSquare(
|
||||
color: Colors.white,
|
||||
size: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
|
||||
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar(
|
||||
{Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
|
||||
: super(key: key);
|
||||
|
||||
final ImmichAsset asset;
|
||||
final Function onMoreInfoPressed;
|
||||
final Function onDownloadPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double iconSize = 18.0;
|
||||
|
||||
return AppBar(
|
||||
@@ -29,7 +34,7 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
print("download");
|
||||
onDownloadPressed();
|
||||
},
|
||||
icon: const Icon(Icons.cloud_download_rounded),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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:flutter_swipe_detector/flutter_swipe_detector.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/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
@@ -25,12 +30,25 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
getAssetExif() async {
|
||||
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(() {
|
||||
getAssetExif();
|
||||
return null;
|
||||
@@ -40,66 +58,76 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
asset: asset,
|
||||
onMoreInfoPressed: () {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.black,
|
||||
barrierColor: Colors.transparent,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
});
|
||||
onMoreInfoPressed: showInfo,
|
||||
onDownloadPressed: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 32,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Failed To Render Image - Possibly Corrupted Data",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
body: SwipeDetector(
|
||||
onSwipeDown: (_) {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
onSwipeUp: (_) {
|
||||
showInfo();
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 32,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Failed To Render Image - Possibly Corrupted Data",
|
||||
textAlign: TextAlign.center,
|
||||
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)
|
||||
const Center(
|
||||
child: DownloadLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
159
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
final String videoUrl;
|
||||
final ImmichAsset asset;
|
||||
ImmichAssetWithExif? assetDetail;
|
||||
final AssetService _assetService = AssetService();
|
||||
|
||||
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
|
||||
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 {
|
||||
assetDetail = await _assetService.getAssetById(asset.id);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
getAssetExif();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
asset: asset,
|
||||
onMoreInfoPressed: () {
|
||||
showInfo();
|
||||
},
|
||||
onDownloadPressed: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SwipeDetector(
|
||||
onSwipeDown: (_) {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
onSwipeUp: (_) {
|
||||
showInfo();
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
VideoThumbnailPlayer(
|
||||
url: videoUrl,
|
||||
jwtToken: jwtToken,
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: DownloadLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoThumbnailPlayer extends StatefulWidget {
|
||||
final String url;
|
||||
final String? jwtToken;
|
||||
|
||||
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
|
||||
}
|
||||
|
||||
class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
||||
late VideoPlayerController videoPlayerController;
|
||||
ChewieController? chewieController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initializePlayer();
|
||||
}
|
||||
|
||||
Future<void> initializePlayer() async {
|
||||
try {
|
||||
videoPlayerController =
|
||||
VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"});
|
||||
|
||||
await videoPlayerController.initialize();
|
||||
_createChewieController();
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
debugPrint("ERROR initialize video player");
|
||||
}
|
||||
}
|
||||
|
||||
_createChewieController() {
|
||||
chewieController = ChewieController(
|
||||
showOptions: true,
|
||||
showControlsOnInitialize: false,
|
||||
videoPlayerController: videoPlayerController,
|
||||
autoPlay: true,
|
||||
autoInitialize: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
videoPlayerController.pause();
|
||||
videoPlayerController.dispose();
|
||||
chewieController?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return chewieController != null && chewieController!.videoPlayerController.value.isInitialized
|
||||
? SizedBox(
|
||||
child: Chewie(
|
||||
controller: chewieController!,
|
||||
),
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 75,
|
||||
height: 75,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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>();
|
||||
}
|
||||
|
||||
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 {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
@@ -73,7 +73,7 @@ class BackupService {
|
||||
});
|
||||
|
||||
// Build thumbnail multipart data
|
||||
var thumbnailData = await entity.thumbDataWithSize(1280, 720);
|
||||
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280));
|
||||
if (thumbnailData != null) {
|
||||
thumbnailUploadData = MultipartFile.fromBytes(
|
||||
List.from(thumbnailData),
|
||||
@@ -112,7 +112,10 @@ class BackupService {
|
||||
}
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError backupAsset: ${e.response}");
|
||||
break;
|
||||
if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
continue;
|
||||
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,9 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/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/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/modules/backup/ui/backup_info_card.dart';
|
||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||
|
||||
class BackupControllerPage extends HookConsumerWidget {
|
||||
@@ -13,16 +16,17 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState _backupState = ref.watch(backupProvider);
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
|
||||
|
||||
bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true;
|
||||
bool shouldBackup =
|
||||
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
|
||||
|
||||
useEffect(() {
|
||||
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
||||
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
||||
ref.read(backupProvider.notifier).getBackupInfo();
|
||||
}
|
||||
|
||||
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
@@ -44,13 +48,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
LinearPercentIndicator(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
lineHeight: 5.0,
|
||||
percent: _backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||
backgroundColor: Colors.grey,
|
||||
progressColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
Padding(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -85,13 +89,16 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontSize: 14),
|
||||
)
|
||||
: Container(),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
isAutoBackup
|
||||
? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
|
||||
: ref.watch(authenticationProvider.notifier).setAutoBackup(true);
|
||||
},
|
||||
child: Text("Turn $backupBtnText Backup"),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
isAutoBackup
|
||||
? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
|
||||
: ref.watch(authenticationProvider.notifier).setAutoBackup(true);
|
||||
},
|
||||
child: Text("Turn $backupBtnText Backup", style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -99,17 +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(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
"Backup",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
||||
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(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
@@ -123,20 +233,21 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
),
|
||||
_buildFolderSelectionTile(),
|
||||
BackupInfoCard(
|
||||
title: "Total",
|
||||
subtitle: "All images and videos on the device",
|
||||
info: "${_backupState.totalAssetCount}",
|
||||
subtitle: "All unique photos and videos from selected albums",
|
||||
info: "${backupState.allUniqueAssets.length}",
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "Backup",
|
||||
subtitle: "Images and videos of the device that are backup on server",
|
||||
info: "${_backupState.assetOnDatabase}",
|
||||
subtitle: "Photos and videos from selected albums that are backup",
|
||||
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "Remainder",
|
||||
subtitle: "Images and videos that has not been backing up",
|
||||
info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}",
|
||||
subtitle: "Photos and videos that has not been backing up from selected albums",
|
||||
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
),
|
||||
const Divider(),
|
||||
_buildBackupController(),
|
||||
@@ -146,14 +257,14 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
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: const EdgeInsets.only(left: 8.0),
|
||||
child: Row(children: [
|
||||
const Text("Backup Progress:"),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? const CircularProgressIndicator.adaptive()
|
||||
: const Text("Done"),
|
||||
]),
|
||||
@@ -161,7 +272,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
child: _backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
child: backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
|
||||
onPressed: () {
|
||||
@@ -185,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: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';
|
||||
|
||||
class DeleteDialog extends ConsumerWidget {
|
||||
|
||||
@@ -11,40 +11,44 @@ class ImageGrid extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
var assetType = assetGroup[index].type;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {},
|
||||
child: Stack(
|
||||
children: [
|
||||
ThumbnailImage(asset: assetGroup[index]),
|
||||
assetType == 'IMAGE'
|
||||
? Container()
|
||||
: Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
assetGroup[index].duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
onTap: () {},
|
||||
child: Stack(
|
||||
children: [
|
||||
ThumbnailImage(asset: assetGroup[index]),
|
||||
assetType == 'IMAGE'
|
||||
? Container()
|
||||
: Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
assetGroup[index].duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
));
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: assetGroup.length,
|
||||
),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.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/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
|
||||
class ImmichSliverAppBar extends ConsumerWidget {
|
||||
@@ -30,7 +29,6 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
backgroundColor: Colors.grey[200],
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
@@ -41,7 +39,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
child: IconButton(
|
||||
splashRadius: 25,
|
||||
icon: const Icon(
|
||||
Icons.account_circle_rounded,
|
||||
Icons.face_outlined,
|
||||
size: 30,
|
||||
),
|
||||
onPressed: () {
|
||||
@@ -79,12 +77,11 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
),
|
||||
title: Text(
|
||||
'IMMICH',
|
||||
style: GoogleFonts.snowburstOne(
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -121,15 +118,20 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
),
|
||||
child: const Icon(Icons.backup_rounded)),
|
||||
tooltip: 'Backup Controller',
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
onPressed: () async {
|
||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
|
||||
if (onPop != null && onPop == true) {
|
||||
onPopBack!();
|
||||
}
|
||||
},
|
||||
),
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
bottom: 5,
|
||||
child: Text(
|
||||
_backupState.backingUpAssetCount.toString(),
|
||||
(_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
|
||||
.toString(),
|
||||
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_hooks/flutter_hooks.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/providers/authentication.provider.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/websocket.provider.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -79,7 +79,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
),
|
||||
title: const Text(
|
||||
"Sign Out",
|
||||
style: TextStyle(color: Colors.black54, fontSize: 14),
|
||||
style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onTap: () async {
|
||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
@@ -65,8 +65,8 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
} else {
|
||||
AutoRouter.of(context).push(
|
||||
VideoViewerRoute(
|
||||
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
|
||||
),
|
||||
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
|
||||
asset: asset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/monthly_title_text.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/websocket.provider.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
@@ -33,6 +32,23 @@ class HomePage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
void reloadAllAsset() {
|
||||
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() {
|
||||
if (assetGroupByDateTime.isNotEmpty) {
|
||||
int? lastMonth;
|
||||
@@ -66,47 +82,51 @@ class HomePage extends HookConsumerWidget {
|
||||
});
|
||||
}
|
||||
|
||||
_buildSliverAppBar() {
|
||||
return isMultiSelectEnable
|
||||
? const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 70,
|
||||
child: null,
|
||||
),
|
||||
)
|
||||
: ImmichSliverAppBar(
|
||||
onPopBack: reloadAllAsset,
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
bottom: !isMultiSelectEnable,
|
||||
top: !isMultiSelectEnable,
|
||||
child: Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
_buildSliverAppBar(),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 50.0),
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAnimatedSwitcher(
|
||||
child: isMultiSelectEnable
|
||||
? const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 70,
|
||||
child: null,
|
||||
),
|
||||
)
|
||||
: const ImmichSliverAppBar(),
|
||||
duration: const Duration(milliseconds: 350),
|
||||
),
|
||||
..._imageGridGroup
|
||||
],
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
..._imageGridGroup,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
isMultiSelectEnable
|
||||
? DisableMultiSelectButton(
|
||||
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
||||
selectedItemCount: homePageState.selectedItems.length,
|
||||
)
|
||||
: Container(),
|
||||
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
|
||||
_buildSelectedItemCountIndicator(),
|
||||
_buildBottomAppBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
// key: _scaffoldKey,
|
||||
drawer: const ProfileDrawer(),
|
||||
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:immich_mobile/constants/hive_box.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/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/network.service.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 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
|
||||
if (serverEndpoint[serverEndpoint.length - 1] == "/") {
|
||||
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
|
||||
@@ -76,6 +77,20 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
userId: payload.userId,
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hive/hive.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/shared/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class LoginForm extends HookConsumerWidget {
|
||||
@@ -13,37 +15,73 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usernameController = useTextEditingController(text: 'testuser@email.com');
|
||||
final passwordController = useTextEditingController(text: 'password');
|
||||
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283');
|
||||
final usernameController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
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(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 32,
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Image(
|
||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
width: 128,
|
||||
width: 100,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
Text(
|
||||
'IMMICH',
|
||||
style: GoogleFonts.snowburstOne(
|
||||
textStyle:
|
||||
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 48,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
EmailInput(controller: usernameController),
|
||||
PasswordInput(controller: passwordController),
|
||||
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(
|
||||
emailController: usernameController,
|
||||
passwordController: passwordController,
|
||||
serverEndpointController: serverEndpointController,
|
||||
isSavedLoginInfo: isSaveLoginInfo.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -102,37 +140,46 @@ class LoginButton extends ConsumerWidget {
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController serverEndpointController;
|
||||
final bool isSavedLoginInfo;
|
||||
|
||||
const LoginButton(
|
||||
{Key? key,
|
||||
required this.emailController,
|
||||
required this.passwordController,
|
||||
required this.serverEndpointController})
|
||||
: super(key: key);
|
||||
const LoginButton({
|
||||
Key? key,
|
||||
required this.emailController,
|
||||
required this.passwordController,
|
||||
required this.serverEndpointController,
|
||||
required this.isSavedLoginInfo,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
style: ButtonStyle(
|
||||
visualDensity: VisualDensity.standard,
|
||||
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 10, horizontal: 25)),
|
||||
),
|
||||
onPressed: () async {
|
||||
// This will remove current cache asset state of previous user login.
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
|
||||
var isAuthenicated = await ref
|
||||
var isAuthenticated = await ref
|
||||
.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
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
// AutoRouter.of(context).pushNamed("/home-page");
|
||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Error logging you in, check server url, email and password!",
|
||||
toastType: ToastType.error);
|
||||
context: context,
|
||||
msg: "Error logging you in, check server url, email and password!",
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Login"));
|
||||
child: const Text(
|
||||
"Login",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
67
mobile/lib/modules/search/ui/thumbnail_with_info.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
|
||||
: super(key: key);
|
||||
|
||||
final String textInfo;
|
||||
final String imageUrl;
|
||||
final Function onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onTap();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black26,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: CachedNetworkImage(
|
||||
width: 250,
|
||||
height: 250,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 10,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Text(
|
||||
textInfo.capitalizeFirstLetter(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
@@ -10,7 +10,9 @@ import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
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/thumbnail_with_info.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';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
@@ -23,8 +25,10 @@ class SearchPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
|
||||
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
|
||||
AsyncValue<List<CuratedLocation>> curatedLocation =
|
||||
ref.watch(getCuratedLocationProvider);
|
||||
AsyncValue<List<CuratedObject>> curatedObjects =
|
||||
ref.watch(getCuratedObjectProvider);
|
||||
|
||||
useEffect(() {
|
||||
searchFocusNode = FocusNode();
|
||||
@@ -40,12 +44,15 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
_buildPlaces() {
|
||||
return curatedLocation.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (curatedLocations) {
|
||||
return curatedLocations.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -59,14 +66,15 @@ class SearchPage extends HookConsumerWidget {
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: locationInfo.city,
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city));
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(searchTerm: locationInfo.city));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -87,12 +95,15 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
_buildThings() {
|
||||
return curatedObjects.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (objects) {
|
||||
return objects.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -106,15 +117,16 @@ class SearchPage extends HookConsumerWidget {
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: curatedObjectInfo.object,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
|
||||
AutoRouter.of(context).push(SearchResultRoute(
|
||||
searchTerm: curatedObjectInfo.object
|
||||
.capitalizeFirstLetter()));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -165,73 +177,12 @@ class SearchPage extends HookConsumerWidget {
|
||||
_buildThings()
|
||||
],
|
||||
),
|
||||
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
||||
isSearchEnabled
|
||||
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
|
||||
: Container(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
|
||||
: super(key: key);
|
||||
|
||||
final String textInfo;
|
||||
final String imageUrl;
|
||||
final Function onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onTap();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black26,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: CachedNetworkImage(
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 10,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Text(
|
||||
textInfo.capitalizeFirstLetter(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
@@ -107,7 +108,10 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (searchResultPageState.isLoading) {
|
||||
return const CircularProgressIndicator.adaptive();
|
||||
return Center(
|
||||
child: SpinKitDancingSquare(
|
||||
color: Theme.of(context).primaryColor,
|
||||
));
|
||||
}
|
||||
|
||||
if (searchResultPageState.isSuccess) {
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||