Compare commits
19 Commits
v1.4.0+7-d
...
v1.7.0_11-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4309104925 | ||
|
|
a3b84b3ca7 | ||
|
|
f6630163b1 | ||
|
|
aebeb37fb0 | ||
|
|
b74ad69288 | ||
|
|
b6579cd38e | ||
|
|
46a2032b9a | ||
|
|
0eb548f115 | ||
|
|
c7dff229db | ||
|
|
8e80825b4f | ||
|
|
a1481c1113 | ||
|
|
3bdcdef198 | ||
|
|
b69f6e0df7 | ||
|
|
be2794a372 | ||
|
|
2ff25b49f4 | ||
|
|
135d72d4cd | ||
|
|
90ef64efa3 | ||
|
|
60df387459 | ||
|
|
fc1acf6f01 |
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@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v2.10.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@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Microservices
|
||||
uses: docker/build-push-action@v2.10.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
|
||||
67
.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
|
||||
|
||||
- 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
|
||||
|
||||
- name: Build and push immich-server release
|
||||
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
|
||||
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@v1.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-microservices release
|
||||
uses: docker/build-push-action@v2.10.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.gpu.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.
|
||||
59
README.md
@@ -34,8 +34,9 @@ Loading ~4000 images/videos
|
||||
<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/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,7 +49,8 @@ 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).
|
||||
- Download asset to local device.
|
||||
- Multi-user supported.
|
||||
- Quick navigation with drag scroll bar.
|
||||
- Auto Backup.
|
||||
@@ -58,11 +60,11 @@ This project is under heavy development, there will be continous functions, feat
|
||||
- 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
|
||||
|
||||
# Development
|
||||
|
||||
@@ -111,18 +113,37 @@ curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
|
||||
|
||||
## Run mobile app
|
||||
|
||||
### Android
|
||||
## F-Droid
|
||||
You can get the app on F-droid by cliking the image below.
|
||||
|
||||
Download `apk` in release tab and run on your phone. You can follow this guide on how to do that
|
||||
[<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)
|
||||
|
||||
|
||||
## Android
|
||||
|
||||
#### Download latest `apk` in release tab and run on your phone. You can follow this guide on how to do that
|
||||
|
||||
- [Run APK on Android](https://www.lifewire.com/install-apk-on-android-4177185)
|
||||
|
||||
### iOS
|
||||
#### You can also download the app from Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
|
||||
You can download the app from 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="250" title="Apple App Store">
|
||||
<img src="design/google-play-qr-code.png" width="200" title="Google Play Store">
|
||||
<p/>
|
||||
|
||||
## iOS
|
||||
|
||||
#### You can download the app from 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
|
||||
@@ -135,14 +156,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/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/search-screen.jpeg
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
design/shared-albums.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
@@ -2,10 +2,10 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.3.2
|
||||
image: immich-server-dev:1.7.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.7.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.7.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.7.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
@@ -2,17 +2,15 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server:1.4.0
|
||||
image: immich-server:1.7.0
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: ../server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
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 +20,18 @@ services:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
restart: unless-stopped
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices:1.4.0
|
||||
image: immich-microservices:1.7.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
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 +40,7 @@ services:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
@@ -83,27 +80,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,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
|
||||
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.7.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
mobile/lib/constants/immich_colors.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const immichBackgroundColor = Color(0xFFf6f8fe);
|
||||
@@ -2,21 +2,20 @@ 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/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/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();
|
||||
await Hive.openBox(userInfoBox);
|
||||
// Hive.registerAdapter(ImmichBackUpAssetAdapter());
|
||||
// Hive.deleteBoxFromDisk(hiveImmichBox);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
@@ -88,26 +87,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: 3,
|
||||
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,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -30,7 +29,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
backgroundColor: Colors.grey[200],
|
||||
// backgroundColor: Colors.grey[200],
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
@@ -41,7 +40,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
child: IconButton(
|
||||
splashRadius: 25,
|
||||
icon: const Icon(
|
||||
Icons.account_circle_rounded,
|
||||
Icons.face_outlined,
|
||||
size: 30,
|
||||
),
|
||||
onPressed: () {
|
||||
@@ -79,12 +78,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,8 +119,12 @@ 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
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
@@ -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,7 +10,7 @@ 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';
|
||||
@@ -33,6 +33,10 @@ class HomePage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
void reloadAllAsset() {
|
||||
ref.read(assetProvider.notifier).getAllAsset();
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (assetGroupByDateTime.isNotEmpty) {
|
||||
int? lastMonth;
|
||||
@@ -86,7 +90,9 @@ class HomePage extends HookConsumerWidget {
|
||||
child: null,
|
||||
),
|
||||
)
|
||||
: const ImmichSliverAppBar(),
|
||||
: ImmichSliverAppBar(
|
||||
onPopBack: reloadAllAsset,
|
||||
),
|
||||
duration: const Duration(milliseconds: 350),
|
||||
),
|
||||
..._imageGridGroup
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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: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/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
@@ -33,9 +32,12 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
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),
|
||||
@@ -117,20 +119,21 @@ class LoginButton extends ConsumerWidget {
|
||||
// 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);
|
||||
|
||||
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"));
|
||||
|
||||
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,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,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();
|
||||
});
|
||||
141
mobile/lib/modules/sharing/services/shared_album.service.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
mobile/lib/modules/sharing/ui/album_viewer_appbar.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
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/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;
|
||||
|
||||
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 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 {
|
||||
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);
|
||||
}
|
||||
181
mobile/lib/modules/sharing/ui/album_viewer_thumbnail.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
final ImmichAsset asset;
|
||||
|
||||
const AlbumViewerThumbnail({Key? key, required this.asset}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
final selectedAssetsInAlbumViewer = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||
|
||||
_viewAsset() {
|
||||
if (asset.type == 'IMAGE') {
|
||||
AutoRouter.of(context).push(
|
||||
ImageViewerRoute(
|
||||
imageUrl:
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
|
||||
heroTag: asset.id,
|
||||
thumbnailUrl: thumbnailRequestUrl,
|
||||
asset: asset,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
AutoRouter.of(context).push(
|
||||
VideoViewerRoute(
|
||||
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
|
||||
asset: asset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxBorder drawBorderColor() {
|
||||
if (selectedAssetsInAlbumViewer.contains(asset)) {
|
||||
return Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
);
|
||||
} else {
|
||||
return const Border();
|
||||
}
|
||||
}
|
||||
|
||||
_enableMultiSelection() {
|
||||
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
|
||||
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
|
||||
}
|
||||
|
||||
_disableMultiSelection() {
|
||||
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||
}
|
||||
|
||||
_buildVideoLabel() {
|
||||
if (asset.type == 'IMAGE') {
|
||||
return Container();
|
||||
} else {
|
||||
return Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_buildAssetStoreLocationIcon() {
|
||||
return Positioned(
|
||||
right: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildAssetSelectionIcon() {
|
||||
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
|
||||
if (isMultiSelectionEnable) {
|
||||
return Positioned(
|
||||
left: 10,
|
||||
top: 5,
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
||||
_buildThumbnailImage() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 300,
|
||||
height: 300,
|
||||
memCacheHeight: 200,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_handleSelectionGesture() {
|
||||
if (selectedAssetsInAlbumViewer.contains(asset)) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAssetsInAlbumViewer([asset]);
|
||||
|
||||
if (selectedAssetsInAlbumViewer.isEmpty) {
|
||||
_disableMultiSelection();
|
||||
}
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isMultiSelectionEnable ? _handleSelectionGesture : _viewAsset,
|
||||
onLongPress: _enableMultiSelection,
|
||||
child: Hero(
|
||||
tag: asset.id,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildThumbnailImage(),
|
||||
_buildAssetStoreLocationIcon(),
|
||||
_buildVideoLabel(),
|
||||
_buildAssetSelectionIcon(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
mobile/lib/modules/sharing/ui/asset_grid_by_month.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/selection_thumbnail_image.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class AssetGridByMonth extends HookConsumerWidget {
|
||||
final List<ImmichAsset> assetGroup;
|
||||
const AssetGridByMonth({Key? key, required this.assetGroup}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return SelectionThumbnailImage(asset: assetGroup[index]);
|
||||
},
|
||||
childCount: assetGroup.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
92
mobile/lib/modules/sharing/ui/month_group_title.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class MonthGroupTitle extends HookConsumerWidget {
|
||||
final String month;
|
||||
final List<ImmichAsset> assetGroup;
|
||||
|
||||
const MonthGroupTitle({Key? key, required this.month, required this.assetGroup}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths;
|
||||
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
_handleTitleIconClick() {
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
if (isAlbumExist) {
|
||||
if (selectedDateGroup.contains(month)) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, []);
|
||||
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets(assetGroup);
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, []);
|
||||
|
||||
// Deep clone assetGroup
|
||||
var assetGroupWithNewItems = [...assetGroup];
|
||||
|
||||
for (var selectedAsset in selectedAssets) {
|
||||
assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id);
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets(assetGroupWithNewItems);
|
||||
}
|
||||
} else {
|
||||
if (selectedDateGroup.contains(month)) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, assetGroup);
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, assetGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getSimplifiedMonth() {
|
||||
var monthAndYear = month.split(',');
|
||||
var yearText = monthAndYear[1].trim();
|
||||
var monthText = monthAndYear[0].trim();
|
||||
var currentYear = DateTime.now().year.toString();
|
||||
|
||||
if (yearText == currentYear) {
|
||||
return monthText;
|
||||
} else {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 14.0, right: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _handleTitleIconClick,
|
||||
child: selectedDateGroup.contains(month)
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.circle_outlined,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
_getSimplifiedMonth(),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
final ImmichAsset asset;
|
||||
|
||||
const SelectionThumbnailImage({Key? key, required this.asset}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
|
||||
var selectedAsset = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
var newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
Widget _buildSelectionIcon(ImmichAsset asset) {
|
||||
if (selectedAsset.contains(asset) && !isAlbumExist) {
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
} else if (selectedAsset.contains(asset) && isAlbumExist) {
|
||||
return const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color.fromARGB(255, 233, 233, 233),
|
||||
);
|
||||
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
} else {
|
||||
return const Icon(
|
||||
Icons.circle_outlined,
|
||||
color: Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxBorder drawBorderColor() {
|
||||
if (selectedAsset.contains(asset) && !isAlbumExist) {
|
||||
return Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
);
|
||||
} else if (selectedAsset.contains(asset) && isAlbumExist) {
|
||||
return Border.all(
|
||||
color: const Color.fromARGB(255, 190, 190, 190),
|
||||
width: 10,
|
||||
);
|
||||
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
|
||||
return Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
);
|
||||
}
|
||||
return const Border();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (isAlbumExist) {
|
||||
// Operation for existing album
|
||||
if (!selectedAsset.contains(asset)) {
|
||||
if (newAssetsForAlbum.contains(asset)) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets([asset]);
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets([asset]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Operation for new album
|
||||
if (selectedAsset.contains(asset)) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeSelectedNewAssets([asset]);
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Hero(
|
||||
tag: asset.id,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 150,
|
||||
height: 150,
|
||||
memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
asset.type == 'IMAGE'
|
||||
? Container()
|
||||
: Positioned(
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
final ImmichAsset asset;
|
||||
|
||||
const SharedAlbumThumbnailImage({Key? key, required this.asset}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// debugPrint("View ${asset.id}");
|
||||
},
|
||||
child: Hero(
|
||||
tag: asset.id,
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
width: 500,
|
||||
height: 500,
|
||||
memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
mobile/lib/modules/sharing/ui/sharing_sliver_appbar.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SharingSliverAppBar extends StatelessWidget {
|
||||
const SharingSliverAppBar({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
leading: Container(),
|
||||
// elevation: 0,
|
||||
title: Text(
|
||||
'IMMICH',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(50.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(Theme.of(context).primaryColor.withAlpha(20)),
|
||||
// foregroundColor: MaterialStateProperty.all(Colors.white),
|
||||
),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const CreateSharedAlbumRoute());
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
size: 20,
|
||||
),
|
||||
label: const Text(
|
||||
"Create shared album",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(Theme.of(context).primaryColor.withAlpha(20)),
|
||||
// foregroundColor: MaterialStateProperty.all(Colors.white),
|
||||
),
|
||||
onPressed: null,
|
||||
icon: const Icon(
|
||||
Icons.swap_horizontal_circle_outlined,
|
||||
size: 20,
|
||||
),
|
||||
label: const Text(
|
||||
"Share with partner",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
mobile/lib/modules/sharing/views/album_viewer_page.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/album_viewer_thumbnail.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class AlbumViewerPage extends HookConsumerWidget {
|
||||
final String albumId;
|
||||
|
||||
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController _scrollController = useScrollController();
|
||||
AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId));
|
||||
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
|
||||
/// Find out if the assets in album exist on the device
|
||||
/// If they exist, add to selected asset state to show they are already selected.
|
||||
void _onAddPhotosPressed(SharedAlbum albumInfo) async {
|
||||
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList());
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
|
||||
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
|
||||
if (returnPayload != null) {
|
||||
// Check if there is new assets add
|
||||
if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
var isSuccess =
|
||||
await SharedAlbumService().addAdditionalAssetToAlbum(returnPayload.selectedAdditionalAsset, albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
void _onAddUsersPressed(SharedAlbum albumInfo) async {
|
||||
List<String>? sharedUserIds =
|
||||
await AutoRouter.of(context).push<List<String>?>(SelectAdditionalUserForSharingRoute(albumInfo: albumInfo));
|
||||
|
||||
if (sharedUserIds != null) {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
var isSuccess = await SharedAlbumService().addAdditionalUserToAlbum(sharedUserIds, albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
|
||||
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||
String startDate = "";
|
||||
DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt);
|
||||
DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt);
|
||||
|
||||
if (parsedStartDate.year == parsedEndDate.year) {
|
||||
startDate = DateFormat('LLL d').format(parsedStartDate);
|
||||
} else {
|
||||
startDate = DateFormat('LLL d, y').format(parsedStartDate);
|
||||
}
|
||||
|
||||
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8),
|
||||
child: Text(
|
||||
"$startDate-$endDate",
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHeader(SharedAlbum albumInfo) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTitle(albumInfo.albumName),
|
||||
_buildAlbumDateRange(albumInfo),
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 18,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
child: Image.asset('assets/immich-logo-no-outline.png'),
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
itemCount: albumInfo.sharedUsers.length,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageGrid(SharedAlbum albumInfo) {
|
||||
if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(asset: albumInfo.sharedAssets![index].assetInfo);
|
||||
},
|
||||
childCount: albumInfo.sharedAssets?.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
|
||||
Widget _buildControlButton(SharedAlbum albumInfo) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
AlbumActionOutlinedButton(
|
||||
iconData: Icons.add_photo_alternate_outlined,
|
||||
onPressed: () => _onAddPhotosPressed(albumInfo),
|
||||
labelText: "Add photos",
|
||||
),
|
||||
userId == albumInfo.ownerId
|
||||
? AlbumActionOutlinedButton(
|
||||
iconData: Icons.person_add_alt_rounded,
|
||||
onPressed: () => _onAddUsersPressed(albumInfo),
|
||||
labelText: "Add users",
|
||||
)
|
||||
: Container(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(SharedAlbum albumInfo) {
|
||||
return Stack(children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildHeader(albumInfo),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: immichBackgroundColor,
|
||||
child: _buildControlButton(albumInfo),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildImageGrid(albumInfo)
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AlbumViewerAppbar(albumInfo: _albumInfo, userId: userId, albumId: albumId),
|
||||
body: _albumInfo.when(
|
||||
data: (albumInfo) => _buildBody(albumInfo),
|
||||
error: (e, _) => Center(child: Text("Error loading album info $e")),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
mobile/lib/modules/sharing/views/asset_selection_page.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/asset_grid_by_month.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/month_group_title.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
|
||||
class AssetSelectionPage extends HookConsumerWidget {
|
||||
const AssetSelectionPage({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController _scrollController = useScrollController();
|
||||
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider);
|
||||
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
List<Widget> _imageGridGroup = [];
|
||||
|
||||
String _buildAssetCountText() {
|
||||
if (isAlbumExist) {
|
||||
return (selectedAssets.length + newAssetsForAlbum.length).toString();
|
||||
} else {
|
||||
return selectedAssets.length.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
assetGroupMonthYear.forEach((monthYear, assetGroup) {
|
||||
_imageGridGroup.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
|
||||
_imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [..._imageGridGroup],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
AutoRouter.of(context).pop(null);
|
||||
},
|
||||
),
|
||||
title: selectedAssets.isEmpty
|
||||
? const Text(
|
||||
'Add photos',
|
||||
style: TextStyle(fontSize: 18),
|
||||
)
|
||||
: Text(
|
||||
_buildAssetCountText(),
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
(!isAlbumExist && selectedAssets.isNotEmpty) || (isAlbumExist && newAssetsForAlbum.isNotEmpty)
|
||||
? TextButton(
|
||||
onPressed: () {
|
||||
var payload = AssetSelectionPageResult(
|
||||
isAlbumExist: isAlbumExist,
|
||||
selectedAdditionalAsset: newAssetsForAlbum,
|
||||
selectedNewAsset: selectedAssets,
|
||||
);
|
||||
AutoRouter.of(context).pop(payload);
|
||||
},
|
||||
child: const Text(
|
||||
"Add",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
}
|
||||
208
mobile/lib/modules/sharing/views/create_shared_album_page.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/album_title_text_field.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/shared_album_thumbnail_image.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class CreateSharedAlbumPage extends HookConsumerWidget {
|
||||
const CreateSharedAlbumPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final albumTitleTextFieldFocusNode = useFocusNode();
|
||||
final isAlbumTitleTextFieldFocus = useState(false);
|
||||
final isAlbumTitleEmpty = useState(true);
|
||||
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
|
||||
_showSelectUserPage() {
|
||||
AutoRouter.of(context).push(const SelectUserForSharingRoute());
|
||||
}
|
||||
|
||||
void _onBackgroundTapped() {
|
||||
albumTitleTextFieldFocusNode.unfocus();
|
||||
isAlbumTitleTextFieldFocus.value = false;
|
||||
|
||||
if (albumTitleController.text.isEmpty) {
|
||||
albumTitleController.text = 'Untitled';
|
||||
ref.watch(albumTitleProvider.notifier).setAlbumTitle('Untitled');
|
||||
}
|
||||
}
|
||||
|
||||
_onSelectPhotosButtonPressed() async {
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
|
||||
|
||||
AssetSelectionPageResult? selectedAsset =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
|
||||
if (selectedAsset == null) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
_buildTitleInputField() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 10,
|
||||
left: 10,
|
||||
),
|
||||
child: AlbumTitleTextField(
|
||||
isAlbumTitleEmpty: isAlbumTitleEmpty,
|
||||
albumTitleTextFieldFocusNode: albumTitleTextFieldFocusNode,
|
||||
albumTitleController: albumTitleController,
|
||||
isAlbumTitleTextFieldFocus: isAlbumTitleTextFieldFocus),
|
||||
);
|
||||
}
|
||||
|
||||
_buildTitle() {
|
||||
if (selectedAssets.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 200, left: 18),
|
||||
child: Text(
|
||||
'ADD ASSETS',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
|
||||
_buildSelectPhotosButton() {
|
||||
if (selectedAssets.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16, left: 18, right: 18),
|
||||
child: OutlinedButton.icon(
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding:
|
||||
MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 22, horizontal: 16)),
|
||||
),
|
||||
onPressed: _onSelectPhotosButtonPressed,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
'Select Photos',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[700], fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
|
||||
_buildControlButton() {
|
||||
if (selectedAssets.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
|
||||
child: SizedBox(
|
||||
height: 30,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
AlbumActionOutlinedButton(
|
||||
iconData: Icons.add_photo_alternate_outlined,
|
||||
onPressed: _onSelectPhotosButtonPressed,
|
||||
labelText: "Add photos",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container();
|
||||
}
|
||||
|
||||
_buildSelectedImageGrid() {
|
||||
if (selectedAssets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return GestureDetector(
|
||||
onTap: _onBackgroundTapped,
|
||||
child: SharedAlbumThumbnailImage(asset: selectedAssets.toList()[index]),
|
||||
);
|
||||
},
|
||||
childCount: selectedAssets.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded)),
|
||||
title: const Text(
|
||||
'Create album',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: albumTitleController.text.isNotEmpty ? _showSelectUserPage : null,
|
||||
child: const Text(
|
||||
'Share',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: _onBackgroundTapped,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
elevation: 5,
|
||||
leading: Container(),
|
||||
pinned: true,
|
||||
floating: false,
|
||||
bottom: PreferredSize(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTitleInputField(),
|
||||
_buildControlButton(),
|
||||
],
|
||||
),
|
||||
preferredSize: const Size.fromHeight(66.0),
|
||||
),
|
||||
),
|
||||
_buildTitle(),
|
||||
_buildSelectPhotosButton(),
|
||||
_buildSelectedImageGrid(),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
|
||||
final SharedAlbum albumInfo;
|
||||
|
||||
const SelectAdditionalUserForSharingPage({Key? key, required this.albumInfo}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||
final sharedUsersList = useState<Set<UserInfo>>({});
|
||||
|
||||
_addNewUsersHandler() {
|
||||
AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList());
|
||||
}
|
||||
|
||||
_buildTileIcon(UserInfo user) {
|
||||
if (sharedUsersList.value.contains(user)) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 25,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return CircleAvatar(
|
||||
backgroundImage: const AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_buildUserList(List<UserInfo> users) {
|
||||
List<Widget> usersChip = [];
|
||||
|
||||
for (var user in sharedUsersList.value) {
|
||||
usersChip.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Chip(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15),
|
||||
label: Text(
|
||||
user.email,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
children: [...usersChip],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Suggestions',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemBuilder: ((context, index) {
|
||||
return ListTile(
|
||||
leading: _buildTileIcon(users[index]),
|
||||
title: Text(
|
||||
users[index].email,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onTap: () {
|
||||
if (sharedUsersList.value.contains(users[index])) {
|
||||
sharedUsersList.value =
|
||||
sharedUsersList.value.where((selectedUser) => selectedUser.id != users[index].id).toSet();
|
||||
} else {
|
||||
sharedUsersList.value = {...sharedUsersList.value, users[index]};
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
itemCount: users.length,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Invite to album',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop(null);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: sharedUsersList.value.isEmpty ? null : _addNewUsersHandler,
|
||||
child: const Text(
|
||||
"Add",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: suggestedShareUsers.when(
|
||||
data: (users) {
|
||||
for (var sharedUsers in albumInfo.sharedUsers) {
|
||||
users.removeWhere((u) => u.id == sharedUsers.sharedUserId || u.id == albumInfo.ownerId);
|
||||
}
|
||||
|
||||
return _buildUserList(users);
|
||||
},
|
||||
error: (e, _) => Text("Error loading suggested users $e"),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
const SelectUserForSharingPage({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sharedUsersList = useState<Set<UserInfo>>({});
|
||||
AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
|
||||
|
||||
_createSharedAlbum() async {
|
||||
var isSuccess = await SharedAlbumService().createSharedAlbum(
|
||||
ref.watch(albumTitleProvider),
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
|
||||
sharedUsersList.value.map((userInfo) => userInfo.id).toList(),
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
}
|
||||
|
||||
const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album')));
|
||||
}
|
||||
|
||||
_buildTileIcon(UserInfo user) {
|
||||
if (sharedUsersList.value.contains(user)) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 25,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return CircleAvatar(
|
||||
backgroundImage: const AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_buildUserList(List<UserInfo> users) {
|
||||
List<Widget> usersChip = [];
|
||||
|
||||
for (var user in sharedUsersList.value) {
|
||||
usersChip.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Chip(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.15),
|
||||
label: Text(
|
||||
user.email,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
children: [...usersChip],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Suggestions',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemBuilder: ((context, index) {
|
||||
return ListTile(
|
||||
leading: _buildTileIcon(users[index]),
|
||||
title: Text(
|
||||
users[index].email,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onTap: () {
|
||||
if (sharedUsersList.value.contains(users[index])) {
|
||||
sharedUsersList.value =
|
||||
sharedUsersList.value.where((selectedUser) => selectedUser.id != users[index].id).toSet();
|
||||
} else {
|
||||
sharedUsersList.value = {...sharedUsersList.value, users[index]};
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
itemCount: users.length,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Invite to album',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () async {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
|
||||
child: const Text(
|
||||
"Create Album",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
))
|
||||
],
|
||||
),
|
||||
body: suggestedShareUsers.when(
|
||||
data: (users) {
|
||||
return _buildUserList(users);
|
||||
},
|
||||
error: (e, _) => Text("Error loading suggested users $e"),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
mobile/lib/modules/sharing/views/sharing_page.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/ui/sharing_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:transparent_image/transparent_image.dart';
|
||||
|
||||
class SharingPage extends HookConsumerWidget {
|
||||
const SharingPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||
final List<SharedAlbum> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(() {
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
_buildAlbumList() {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
String thumbnailUrl = sharedAlbums[index].albumThumbnailAssetId != null
|
||||
? "$thumbnailRequestUrl/${sharedAlbums[index].albumThumbnailAssetId}"
|
||||
: "https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60";
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: FadeInImage(
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
image: NetworkImage(
|
||||
thumbnailUrl,
|
||||
headers: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
fadeOutDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
sharedAlbums[index].albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey.shade800),
|
||||
),
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: sharedAlbums.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildEmptyListIndication() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5.0, bottom: 5),
|
||||
child: Icon(
|
||||
Icons.offline_share_outlined,
|
||||
size: 50,
|
||||
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'EMPTY LIST',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Create shared albums to share photos and videos with people in your network.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const SharingSliverAppBar(),
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
"Shared albums",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
sharedAlbums.isNotEmpty ? _buildAlbumList() : _buildEmptyListIndication()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
|
||||
import 'package:immich_mobile/modules/sharing/views/album_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/sharing/views/asset_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/sharing/views/create_shared_album_page.dart';
|
||||
import 'package:immich_mobile/modules/sharing/views/select_additional_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/sharing/views/sharing_page.dart';
|
||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:immich_mobile/shared/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
@@ -22,13 +30,31 @@ part 'router.gr.dart';
|
||||
guards: [AuthGuard],
|
||||
children: [
|
||||
AutoRoute(page: HomePage, guards: [AuthGuard]),
|
||||
AutoRoute(page: SearchPage, guards: [AuthGuard])
|
||||
AutoRoute(page: SearchPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: SharingPage, guards: [AuthGuard])
|
||||
],
|
||||
),
|
||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: CreateSharedAlbumPage, guards: [AuthGuard]),
|
||||
CustomRoute<AssetSelectionPageResult?>(
|
||||
page: AssetSelectionPage,
|
||||
guards: [AuthGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
CustomRoute<List<String>>(
|
||||
page: SelectUserForSharingPage,
|
||||
guards: [AuthGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
AutoRoute(page: AlbumViewerPage, guards: [AuthGuard]),
|
||||
CustomRoute<List<String>?>(
|
||||
page: SelectAdditionalUserForSharingPage,
|
||||
guards: [AuthGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
||||
@@ -44,7 +44,8 @@ class _$AppRouter extends RootStackRouter {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl));
|
||||
child: VideoViewerPage(
|
||||
key: args.key, videoUrl: args.videoUrl, asset: args.asset));
|
||||
},
|
||||
BackupControllerRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
@@ -56,6 +57,42 @@ class _$AppRouter extends RootStackRouter {
|
||||
routeData: routeData,
|
||||
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
|
||||
},
|
||||
CreateSharedAlbumRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const CreateSharedAlbumPage());
|
||||
},
|
||||
AssetSelectionRoute.name: (routeData) {
|
||||
return CustomPage<AssetSelectionPageResult?>(
|
||||
routeData: routeData,
|
||||
child: const AssetSelectionPage(),
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
opaque: true,
|
||||
barrierDismissible: false);
|
||||
},
|
||||
SelectUserForSharingRoute.name: (routeData) {
|
||||
return CustomPage<List<String>>(
|
||||
routeData: routeData,
|
||||
child: const SelectUserForSharingPage(),
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
opaque: true,
|
||||
barrierDismissible: false);
|
||||
},
|
||||
AlbumViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AlbumViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: AlbumViewerPage(key: args.key, albumId: args.albumId));
|
||||
},
|
||||
SelectAdditionalUserForSharingRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<SelectAdditionalUserForSharingRouteArgs>();
|
||||
return CustomPage<List<String>?>(
|
||||
routeData: routeData,
|
||||
child: SelectAdditionalUserForSharingPage(
|
||||
key: args.key, albumInfo: args.albumInfo),
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
opaque: true,
|
||||
barrierDismissible: false);
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const HomePage());
|
||||
@@ -65,6 +102,10 @@ class _$AppRouter extends RootStackRouter {
|
||||
orElse: () => const SearchRouteArgs());
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: SearchPage(key: args.key));
|
||||
},
|
||||
SharingRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const SharingPage());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,6 +125,10 @@ class _$AppRouter extends RootStackRouter {
|
||||
RouteConfig(SearchRoute.name,
|
||||
path: 'search-page',
|
||||
parent: TabControllerRoute.name,
|
||||
guards: [authGuard]),
|
||||
RouteConfig(SharingRoute.name,
|
||||
path: 'sharing-page',
|
||||
parent: TabControllerRoute.name,
|
||||
guards: [authGuard])
|
||||
]),
|
||||
RouteConfig(ImageViewerRoute.name,
|
||||
@@ -93,7 +138,18 @@ class _$AppRouter extends RootStackRouter {
|
||||
RouteConfig(BackupControllerRoute.name,
|
||||
path: '/backup-controller-page', guards: [authGuard]),
|
||||
RouteConfig(SearchResultRoute.name,
|
||||
path: '/search-result-page', guards: [authGuard])
|
||||
path: '/search-result-page', guards: [authGuard]),
|
||||
RouteConfig(CreateSharedAlbumRoute.name,
|
||||
path: '/create-shared-album-page', guards: [authGuard]),
|
||||
RouteConfig(AssetSelectionRoute.name,
|
||||
path: '/asset-selection-page', guards: [authGuard]),
|
||||
RouteConfig(SelectUserForSharingRoute.name,
|
||||
path: '/select-user-for-sharing-page', guards: [authGuard]),
|
||||
RouteConfig(AlbumViewerRoute.name,
|
||||
path: '/album-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(SelectAdditionalUserForSharingRoute.name,
|
||||
path: '/select-additional-user-for-sharing-page',
|
||||
guards: [authGuard])
|
||||
];
|
||||
}
|
||||
|
||||
@@ -163,24 +219,29 @@ class ImageViewerRouteArgs {
|
||||
/// generated route for
|
||||
/// [VideoViewerPage]
|
||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
VideoViewerRoute({Key? key, required String videoUrl})
|
||||
VideoViewerRoute(
|
||||
{Key? key, required String videoUrl, required ImmichAsset asset})
|
||||
: super(VideoViewerRoute.name,
|
||||
path: '/video-viewer-page',
|
||||
args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl));
|
||||
args: VideoViewerRouteArgs(
|
||||
key: key, videoUrl: videoUrl, asset: asset));
|
||||
|
||||
static const String name = 'VideoViewerRoute';
|
||||
}
|
||||
|
||||
class VideoViewerRouteArgs {
|
||||
const VideoViewerRouteArgs({this.key, required this.videoUrl});
|
||||
const VideoViewerRouteArgs(
|
||||
{this.key, required this.videoUrl, required this.asset});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String videoUrl;
|
||||
|
||||
final ImmichAsset asset;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}';
|
||||
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +278,86 @@ class SearchResultRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [CreateSharedAlbumPage]
|
||||
class CreateSharedAlbumRoute extends PageRouteInfo<void> {
|
||||
const CreateSharedAlbumRoute()
|
||||
: super(CreateSharedAlbumRoute.name, path: '/create-shared-album-page');
|
||||
|
||||
static const String name = 'CreateSharedAlbumRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AssetSelectionPage]
|
||||
class AssetSelectionRoute extends PageRouteInfo<void> {
|
||||
const AssetSelectionRoute()
|
||||
: super(AssetSelectionRoute.name, path: '/asset-selection-page');
|
||||
|
||||
static const String name = 'AssetSelectionRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SelectUserForSharingPage]
|
||||
class SelectUserForSharingRoute extends PageRouteInfo<void> {
|
||||
const SelectUserForSharingRoute()
|
||||
: super(SelectUserForSharingRoute.name,
|
||||
path: '/select-user-for-sharing-page');
|
||||
|
||||
static const String name = 'SelectUserForSharingRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AlbumViewerPage]
|
||||
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
||||
AlbumViewerRoute({Key? key, required String albumId})
|
||||
: super(AlbumViewerRoute.name,
|
||||
path: '/album-viewer-page',
|
||||
args: AlbumViewerRouteArgs(key: key, albumId: albumId));
|
||||
|
||||
static const String name = 'AlbumViewerRoute';
|
||||
}
|
||||
|
||||
class AlbumViewerRouteArgs {
|
||||
const AlbumViewerRouteArgs({this.key, required this.albumId});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String albumId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AlbumViewerRouteArgs{key: $key, albumId: $albumId}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SelectAdditionalUserForSharingPage]
|
||||
class SelectAdditionalUserForSharingRoute
|
||||
extends PageRouteInfo<SelectAdditionalUserForSharingRouteArgs> {
|
||||
SelectAdditionalUserForSharingRoute(
|
||||
{Key? key, required SharedAlbum albumInfo})
|
||||
: super(SelectAdditionalUserForSharingRoute.name,
|
||||
path: '/select-additional-user-for-sharing-page',
|
||||
args: SelectAdditionalUserForSharingRouteArgs(
|
||||
key: key, albumInfo: albumInfo));
|
||||
|
||||
static const String name = 'SelectAdditionalUserForSharingRoute';
|
||||
}
|
||||
|
||||
class SelectAdditionalUserForSharingRouteArgs {
|
||||
const SelectAdditionalUserForSharingRouteArgs(
|
||||
{this.key, required this.albumInfo});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final SharedAlbum albumInfo;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SelectAdditionalUserForSharingRouteArgs{key: $key, albumInfo: $albumInfo}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
@@ -245,3 +386,11 @@ class SearchRouteArgs {
|
||||
return 'SearchRouteArgs{key: $key}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SharingPage]
|
||||
class SharingRoute extends PageRouteInfo<void> {
|
||||
const SharingRoute() : super(SharingRoute.name, path: 'sharing-page');
|
||||
|
||||
static const String name = 'SharingRoute';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
|
||||
class TabNavigationObserver extends AutoRouterObserver {
|
||||
@@ -21,7 +22,8 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
|
||||
Future<void> didChangeTabRoute(
|
||||
TabPageRoute route, TabPageRoute previousRoute) async {
|
||||
// Perform tasks on re-visit to SearchRoute
|
||||
if (route.name == 'SearchRoute') {
|
||||
// Refresh Location State
|
||||
@@ -29,6 +31,10 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
ref.refresh(getCuratedObjectProvider);
|
||||
}
|
||||
|
||||
if (route.name == 'SharingRoute') {
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
}
|
||||
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ class ImmichAsset {
|
||||
final String modifiedAt;
|
||||
final bool isFavorite;
|
||||
final String? duration;
|
||||
final String originalPath;
|
||||
final String resizePath;
|
||||
|
||||
ImmichAsset({
|
||||
required this.id,
|
||||
@@ -21,6 +23,8 @@ class ImmichAsset {
|
||||
required this.modifiedAt,
|
||||
required this.isFavorite,
|
||||
this.duration,
|
||||
required this.originalPath,
|
||||
required this.resizePath,
|
||||
});
|
||||
|
||||
ImmichAsset copyWith({
|
||||
@@ -33,6 +37,8 @@ class ImmichAsset {
|
||||
String? modifiedAt,
|
||||
bool? isFavorite,
|
||||
String? duration,
|
||||
String? originalPath,
|
||||
String? resizePath,
|
||||
}) {
|
||||
return ImmichAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -44,6 +50,8 @@ class ImmichAsset {
|
||||
modifiedAt: modifiedAt ?? this.modifiedAt,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
duration: duration ?? this.duration,
|
||||
originalPath: originalPath ?? this.originalPath,
|
||||
resizePath: resizePath ?? this.resizePath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +66,8 @@ class ImmichAsset {
|
||||
'modifiedAt': modifiedAt,
|
||||
'isFavorite': isFavorite,
|
||||
'duration': duration,
|
||||
'originalPath': originalPath,
|
||||
'resizePath': resizePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +82,8 @@ class ImmichAsset {
|
||||
modifiedAt: map['modifiedAt'] ?? '',
|
||||
isFavorite: map['isFavorite'] ?? false,
|
||||
duration: map['duration'],
|
||||
originalPath: map['originalPath'] ?? '',
|
||||
resizePath: map['resizePath'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +93,7 @@ class ImmichAsset {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration)';
|
||||
return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration, originalPath: $originalPath, resizePath: $resizePath)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -97,7 +109,9 @@ class ImmichAsset {
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.duration == duration;
|
||||
other.duration == duration &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,6 +124,8 @@ class ImmichAsset {
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
duration.hashCode;
|
||||
duration.hashCode ^
|
||||
originalPath.hashCode ^
|
||||
resizePath.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
60
mobile/lib/shared/models/user_info.model.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class UserInfo {
|
||||
final String id;
|
||||
final String email;
|
||||
final String createdAt;
|
||||
|
||||
UserInfo({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? createdAt,
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'id': id});
|
||||
result.addAll({'email': email});
|
||||
result.addAll({'createdAt': createdAt});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory UserInfo.fromMap(Map<String, dynamic> map) {
|
||||
return UserInfo(
|
||||
id: map['id'] ?? '',
|
||||
email: map['email'] ?? '',
|
||||
createdAt: map['createdAt'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory UserInfo.fromJson(String source) => UserInfo.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is UserInfo && other.id == id && other.email == email && other.createdAt == createdAt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ email.hashCode ^ createdAt.hashCode;
|
||||
}
|
||||
@@ -72,3 +72,11 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
|
||||
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
|
||||
});
|
||||
|
||||
final assetGroupByMonthYearProvider = StateProvider((ref) {
|
||||
var assets = ref.watch(assetProvider);
|
||||
|
||||
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||
|
||||
return assets.groupListsBy((element) => DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)));
|
||||
});
|
||||
@@ -37,24 +37,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
Ref? ref;
|
||||
final BackupService _backupService = BackupService();
|
||||
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||
final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast();
|
||||
final StreamController _onAssetBackupStreamCtrl =
|
||||
StreamController.broadcast();
|
||||
|
||||
void getBackupInfo() async {
|
||||
_updateServerInfo();
|
||||
|
||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common);
|
||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
|
||||
onlyAll: true, type: RequestType.common);
|
||||
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
|
||||
|
||||
if (list.isEmpty) {
|
||||
debugPrint("No Asset On Device");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length);
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
totalAssetCount: 0,
|
||||
assetOnDatabase: didBackupAsset.length);
|
||||
return;
|
||||
}
|
||||
|
||||
int totalAsset = list[0].assetCount;
|
||||
|
||||
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
||||
state = state.copyWith(
|
||||
totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
||||
}
|
||||
|
||||
void startBackupProcess() async {
|
||||
@@ -67,8 +72,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
await PhotoManager.clearFileCache();
|
||||
// await PhotoManager.presentLimited();
|
||||
// Gather assets info
|
||||
List<AssetPathEntity> list =
|
||||
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
|
||||
hasAll: true, onlyAll: true, type: RequestType.common);
|
||||
|
||||
// Get device assets info from database
|
||||
// Compare and find different assets that has not been backing up
|
||||
@@ -78,14 +83,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
if (list.isEmpty) {
|
||||
debugPrint("No Asset On Device - Abort Backup Process");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: backupAsset.length);
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
totalAssetCount: 0,
|
||||
assetOnDatabase: backupAsset.length);
|
||||
return;
|
||||
}
|
||||
|
||||
int totalAsset = list[0].assetCount;
|
||||
List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset);
|
||||
List<AssetEntity> currentAssets =
|
||||
await list[0].getAssetListRange(start: 0, end: totalAsset);
|
||||
|
||||
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
||||
state = state.copyWith(
|
||||
totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
||||
// Remove item that has already been backed up
|
||||
for (var backupAssetId in backupAsset) {
|
||||
currentAssets.removeWhere((e) => e.id == backupAssetId);
|
||||
@@ -97,9 +106,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
state = state.copyWith(backingUpAssetCount: currentAssets.length);
|
||||
|
||||
// Perform Packup
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancelToken());
|
||||
_backupService.backupAsset(currentAssets, state.cancelToken, _onAssetUploaded, _onUploadProgress);
|
||||
_backupService.backupAsset(currentAssets, state.cancelToken,
|
||||
_onAssetUploaded, _onUploadProgress);
|
||||
} else {
|
||||
PhotoManager.openSetting();
|
||||
}
|
||||
@@ -107,22 +117,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
void cancelBackup() {
|
||||
state.cancelToken.cancel('Cancel Backup');
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
state =
|
||||
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
|
||||
state = state.copyWith(
|
||||
backingUpAssetCount: state.backingUpAssetCount - 1,
|
||||
assetOnDatabase: state.assetOnDatabase + 1);
|
||||
|
||||
if (state.backingUpAssetCount == 0) {
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.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));
|
||||
state = state.copyWith(
|
||||
progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||
}
|
||||
|
||||
void _updateServerInfo() async {
|
||||
@@ -156,7 +170,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||
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");
|
||||
@@ -173,6 +188,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
final backupProvider =
|
||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(ref: ref);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.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/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart';
|
||||
|
||||
@@ -106,6 +106,20 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopListenToEvent(String eventName) {
|
||||
debugPrint("[Websocket] Stop listening to event $eventName");
|
||||
state.socket?.off(eventName);
|
||||
}
|
||||
|
||||
listenUploadEvent() {
|
||||
debugPrint("[Websocket] Start listening to event on_upload_success");
|
||||
state.socket?.on('on_upload_success', (data) {
|
||||
var jsonString = jsonDecode(data.toString());
|
||||
ImmichAsset newAsset = ImmichAsset.fromMap(jsonString);
|
||||
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -25,16 +26,36 @@ class NetworkService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> getRequest({required String url}) async {
|
||||
Future<dynamic> getRequest({required String url, bool isByteResponse = false, bool isStreamReponse = false}) async {
|
||||
try {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
Response res = await dio.get('$savedEndpoint/$url');
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
return res;
|
||||
if (isByteResponse) {
|
||||
Response<List<int>> res = await dio.get<List<int>>(
|
||||
'$savedEndpoint/$url',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
return res;
|
||||
}
|
||||
} else if (isStreamReponse) {
|
||||
Response<ResponseBody> res = await dio.get<ResponseBody>(
|
||||
'$savedEndpoint/$url',
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
return res;
|
||||
}
|
||||
} else {
|
||||
Response res = await dio.get('$savedEndpoint/$url');
|
||||
if (res.statusCode == 200) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError: ${e.response}");
|
||||
@@ -55,9 +76,10 @@ class NetworkService {
|
||||
return res;
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError: ${e.response}");
|
||||
return false;
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR BackupService: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
mobile/lib/shared/services/user.service.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/user_info.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
|
||||
class UserService {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
|
||||
Future<List<UserInfo>> getAllUsersInfo() async {
|
||||
try {
|
||||
Response res = await _networkService.getRequest(url: 'user');
|
||||
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||
List<UserInfo> result = List.from(decodedData.map((e) => UserInfo.fromMap(e)));
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("Error getAllUsersInfo ${e.toString()}");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
24
mobile/lib/shared/ui/immich_loading_indicator.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
|
||||
class ImmichLoadingIndicator extends StatelessWidget {
|
||||
const ImmichLoadingIndicator({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const SpinKitDancingSquare(
|
||||
color: Colors.white,
|
||||
size: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImmichSliverPersistentAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double minHeight;
|
||||
final double maxHeight;
|
||||
final Widget child;
|
||||
|
||||
ImmichSliverPersistentAppBarDelegate({
|
||||
required this.minHeight,
|
||||
required this.maxHeight,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
double get minExtent => minHeight;
|
||||
|
||||
@override
|
||||
double get maxExtent => max(maxHeight, minHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return SizedBox.expand(child: child);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(ImmichSliverPersistentAppBarDelegate oldDelegate) {
|
||||
return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child;
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,24 @@ class ImmichToast {
|
||||
required BuildContext context,
|
||||
required String msg,
|
||||
ToastType toastType = ToastType.info,
|
||||
ToastGravity gravity = ToastGravity.TOP,
|
||||
}) {
|
||||
FToast fToast;
|
||||
|
||||
fToast = FToast();
|
||||
fToast.init(context);
|
||||
|
||||
_getColor(ToastType type, BuildContext context) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return Theme.of(context).primaryColor;
|
||||
case ToastType.success:
|
||||
return const Color.fromARGB(255, 78, 140, 124);
|
||||
case ToastType.error:
|
||||
return const Color.fromARGB(255, 220, 48, 85);
|
||||
}
|
||||
}
|
||||
|
||||
fToast.showToast(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
@@ -36,8 +48,8 @@ class ImmichToast {
|
||||
: Container(),
|
||||
(toastType == ToastType.success)
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Color.fromARGB(255, 104, 248, 140),
|
||||
Icons.check_circle_rounded,
|
||||
color: Color.fromARGB(255, 78, 140, 124),
|
||||
)
|
||||
: Container(),
|
||||
(toastType == ToastType.error)
|
||||
@@ -53,7 +65,7 @@ class ImmichToast {
|
||||
child: Text(
|
||||
msg,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: _getColor(toastType, context),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
@@ -62,7 +74,7 @@ class ImmichToast {
|
||||
],
|
||||
),
|
||||
),
|
||||
gravity: ToastGravity.TOP,
|
||||
gravity: gravity,
|
||||
toastDuration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
|
||||
import 'package:immich_mobile/shared/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/shared/providers/websocket.provider.dart';
|
||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||
|
||||
class BackupControllerPage extends HookConsumerWidget {
|
||||
@@ -23,6 +24,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
ref.read(backupProvider.notifier).getBackupInfo();
|
||||
}
|
||||
|
||||
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
@@ -85,13 +87,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)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -107,6 +112,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
||||
AutoRouter.of(context).pop(true);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded)),
|
||||
|
||||
41
mobile/lib/shared/views/immich_loading_overlay.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class ImmichLoadingOverlay extends StatelessWidget {
|
||||
const ImmichLoadingOverlay({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
|
||||
builder: (context, shouldShow, child) {
|
||||
if (shouldShow) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black54,
|
||||
body: Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichLoadingOverlayController {
|
||||
static final ImmichLoadingOverlayController appLoader = ImmichLoadingOverlayController();
|
||||
ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
|
||||
ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');
|
||||
|
||||
void show() {
|
||||
loaderShowingNotifier.value = true;
|
||||
}
|
||||
|
||||
void hide() {
|
||||
loaderShowingNotifier.value = false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class TabControllerPage extends ConsumerWidget {
|
||||
routes: [
|
||||
const HomeRoute(),
|
||||
SearchRoute(),
|
||||
const SharingRoute(),
|
||||
],
|
||||
builder: (context, child, animation) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
@@ -34,7 +35,8 @@ class TabControllerPage extends ConsumerWidget {
|
||||
},
|
||||
items: const [
|
||||
BottomNavigationBarItem(label: 'Photos', icon: Icon(Icons.photo)),
|
||||
BottomNavigationBarItem(label: 'Seach', icon: Icon(Icons.search)),
|
||||
BottomNavigationBarItem(label: 'Search', icon: Icon(Icons.search)),
|
||||
BottomNavigationBarItem(label: 'Sharing', icon: Icon(Icons.group_outlined)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class VideoViewerPage extends StatelessWidget {
|
||||
final String videoUrl;
|
||||
|
||||
const VideoViewerPage({Key? key, required this.videoUrl}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
backgroundColor: Colors.black,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios)),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: VideoThumbnailPlayer(
|
||||
url: videoUrl,
|
||||
jwtToken: jwtToken,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +35,9 @@ class FileHelper {
|
||||
case 'dng':
|
||||
return {"type": "image", "subType": "dng"};
|
||||
|
||||
case 'webp':
|
||||
return {"type": "image", "subType": "webp"};
|
||||
|
||||
default:
|
||||
return {"type": "unsupport", "subType": "unsupport"};
|
||||
}
|
||||
|
||||