Compare commits

...

19 Commits

Author SHA1 Message Date
Alex
4309104925 118 - Implement shared album feature (#124)
* 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.
2022-04-23 21:08:45 -05:00
Alex Tran
a3b84b3ca7 Update readme 2022-04-05 17:29:02 -05:00
Alex Tran
f6630163b1 update readme 2022-04-05 17:27:37 -05:00
Chiogros
aebeb37fb0 Legacy CPUs architecture alternative tip. (#93) 2022-04-05 17:26:42 -05:00
Alex
b74ad69288 Fixed duplicated filename on upload 2022-04-05 14:57:54 -05:00
Alex Tran
b6579cd38e Fixed incorrect image name when push to dockerhub 2022-04-05 11:43:09 -05:00
Alex Tran
46a2032b9a Update release workflow 2022-04-05 11:08:30 -05:00
Alex Tran
0eb548f115 Update readme 2022-04-05 11:04:53 -05:00
Alex
c7dff229db Up minor v1.6.0 (#113) 2022-04-05 10:34:54 -05:00
Alex
8e80825b4f Build and tag docker image for Dockerhub release (#111)
* Clean up Dockerfile and added action to build microservice latest
* Combine build microservices and server into the same action
* Added build and push release version for microservices
2022-04-05 10:16:15 -05:00
Constantin Kraft
a1481c1113 Fix typo: Reserve -> Reverse (geocoding) (#112) 2022-04-05 10:11:40 -05:00
Alex
3bdcdef198 Fixed backup stuck at unsupported format (#108)
* Added webp as supported file type, allow continue upload when an image fail

* Added webp as supported file type, allow continue upload when an image fail

* Solved issue with bad assets cause backup to stop
2022-04-04 23:37:48 -05:00
Alex
b69f6e0df7 Update inline font for f-droid publication metric (#107)
* Added local font
* Up Patch 1.5.1+9
2022-04-04 09:08:53 -05:00
Alex
be2794a372 Optimization/fix slow backup when asset list is long. (#104)
* Handle pause/restart listening to event on_upload_success and reload asset list after navigating back from BackupControllerPage
* Remove unused api endpoint
2022-04-03 12:31:45 -05:00
Alex Tran
2ff25b49f4 Up Minor 1.5.0+8 2022-04-02 12:46:29 -05:00
Alex Tran
135d72d4cd Fixed issue with docker-compose cannot navigate to relative path of Dockercompose file in issue #90 2022-04-02 12:37:57 -05:00
Alex
90ef64efa3 Download asset to local and error fixing (#100)
* Update photo_manager pub package
* Added download endpoint for assets
* Successfully save a photo to the local device's gallery
* Save save a video to the local device's gallery
* Fixed #97
* Added download loading indicator
* Refactor and increase the font size for curated search thumbnail images
* Reposition loading animation on the search result page
2022-04-02 12:31:53 -05:00
Alex Tran
60df387459 Update fdroid app description 2022-03-30 13:14:09 -05:00
Alex Tran
fc1acf6f01 Remove release build on github action 2022-03-29 22:10:21 -05:00
131 changed files with 4551 additions and 823 deletions

View 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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
name: Build Server - Release name: Build and push Docker image - Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,35 +6,78 @@ on:
types: [published] types: [published]
jobs: jobs:
buildandpush: build_and_push_server_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
ref: "main" # branch ref: "main"
# https://github.com/docker/setup-qemu-action#usage fetch-depth: 0
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0 uses: docker/setup-qemu-action@v1.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v1.6.0 uses: docker/setup-buildx-action@v1.6.0
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 uses: docker/build-push-action@v2.10.0
with: with:
context: ./server context: ./server
file: ./server/Dockerfile 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 platforms: linux/arm/v7,linux/amd64,linux/arm64
pull: true push: ${{ github.event_name != 'pull_request' }}
push: true
tags: | 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 }}

View File

@@ -9,3 +9,6 @@ dev-scale:
prod: 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

View File

@@ -1,13 +1,17 @@
# Deployment checklist for iOS/Android/Server # 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. All of the version should be the same.

View File

@@ -34,8 +34,9 @@ Loading ~4000 images/videos
<p align="left"> <p align="left">
<img src="design/nsc1.png" width="150" title="Login With Custom URL"> <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/nsc2.png" width="150" title="Backup Setting Info">
<img src="design/nsc3.png" width="150" title="Multiple select"> <img src="design/home-screen.jpeg" width="150" title="Home Screen">
<img src="design/nsc4.jpeg" width="150" title="Curated Search Info"> <img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
<img src="design/shared-albums.png" width="150" title="Shared Albums">
<img src="design/nsc6.png" width="150" title="EXIF Info"> <img src="design/nsc6.png" width="150" title="EXIF Info">
</p> </p>
@@ -48,7 +49,8 @@ This project is under heavy development, there will be continous functions, feat
# Features # Features
- Upload and view assets(videos/images). - Upload and view assets (videos/images).
- Download asset to local device.
- Multi-user supported. - Multi-user supported.
- Quick navigation with drag scroll bar. - Quick navigation with drag scroll bar.
- Auto Backup. - 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 - Image Tagging/Classification based on ImageNet dataset
- Object detection based on COCO SSD. - Object detection based on COCO SSD.
- Search assets based on tags and exif data (lens, make, model, orientation) - Search assets based on tags and exif data (lens, make, model, orientation)
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich) - [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- Show asset's location information on map (OpenStreetMap). - Show asset's location information on map (OpenStreetMap).
- Show curated places on the search page - Show curated places on the search page
- Show curated objects on the search page - Show curated objects on the search page
- Shared album with users on the same server
# Development # Development
@@ -111,18 +113,37 @@ curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
## Run mobile app ## 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) - [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"> <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/> <p/>
# Support # Support
@@ -135,7 +156,11 @@ Cheer! 🎉
# Known Issue # Known Issue
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture. ## TensorFlow Build Issue
*This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup*
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
```bash ```bash
more /proc/cpuinfo | grep flags more /proc/cpuinfo | grep flags
@@ -146,3 +171,7 @@ If you are running virtualization in Promox, the VM doesn't have the flag enable
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab. You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host` `Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
Otherwise you can:
- edit `docker-compose.yml` file and comment the whole `immich_microservices` service **which will disable machine learning features like object detection and image classification**
- switch to a different VM/desktop with different architecture.

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
design/home-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
design/search-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

BIN
design/shared-albums.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -2,10 +2,10 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.3.2 image: immich-server-dev:1.7.0
build: build:
context: ../server context: ../server
dockerfile: ../server/Dockerfile dockerfile: Dockerfile
command: npm run start:dev command: npm run start:dev
expose: expose:
- "3000" - "3000"
@@ -24,10 +24,10 @@ services:
- immich_network - immich_network
immich_microservices: immich_microservices:
image: immich-microservices-dev:1.3.2 image: immich-microservices-dev:1.7.0
build: build:
context: ../microservices context: ../microservices
dockerfile: ../microservices/Dockerfile dockerfile: Dockerfile
command: npm run start:dev command: npm run start:dev
expose: expose:
- "3001" - "3001"

View File

@@ -2,10 +2,10 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.4.0 image: immich-server-dev:1.7.0
build: build:
context: ../server context: ../server
dockerfile: ../server/Dockerfile dockerfile: Dockerfile
command: npm run start:dev command: npm run start:dev
expose: expose:
- "3000" - "3000"
@@ -22,10 +22,10 @@ services:
- immich_network - immich_network
immich_microservices: immich_microservices:
image: immich-microservices-dev:1.4.0 image: immich-microservices-dev:1.7.0
build: build:
context: ../microservices context: ../microservices
dockerfile: ../microservices/Dockerfile dockerfile: Dockerfile
command: npm run start:dev command: npm run start:dev
deploy: deploy:
resources: resources:

View File

@@ -2,17 +2,15 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server:1.4.0 image: immich-server:1.7.0
build: build:
context: ../server context: ../server
dockerfile: ../server/Dockerfile dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose: expose:
- "3000" - "3000"
volumes: volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file: env_file:
- .env - .env
environment: environment:
@@ -22,19 +20,18 @@ services:
- database - database
networks: networks:
- immich_network - immich_network
restart: unless-stopped
immich_microservices: immich_microservices:
image: immich-microservices:1.4.0 image: immich-microservices:1.7.0
build: build:
context: ../microservices context: ../microservices
dockerfile: ../microservices/Dockerfile dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose: expose:
- "3001" - "3001"
volumes: volumes:
- ../microservices:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file: env_file:
- .env - .env
environment: environment:
@@ -43,7 +40,7 @@ services:
- database - database
networks: networks:
- immich_network - immich_network
restart: unless-stopped
redis: redis:
container_name: immich_redis container_name: immich_redis
@@ -83,27 +80,6 @@ services:
depends_on: depends_on:
- immich_server - 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: networks:
immich_network: immich_network:
volumes: volumes:

View File

@@ -10,11 +10,22 @@ map $http_upgrade $connection_upgrade {
server { server {
gzip on;
gzip_min_length 1000;
gunzip on;
client_max_body_size 50000M; client_max_body_size 50000M;
listen 80; listen 80;
access_log off; access_log off;
location / { location / {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off; proxy_buffering off;
proxy_buffer_size 16k; proxy_buffer_size 16k;
proxy_busy_buffers_size 24k; proxy_busy_buffers_size 24k;

View File

@@ -39,6 +39,7 @@ export class ImageClassifierService {
} }
} }
tf.dispose(decodedImage);
return tags; return tags;
} }
} catch (e) { } catch (e) {

View File

@@ -29,6 +29,7 @@ export class ObjectDetectionService {
} }
} }
tf.dispose(decodedImage);
return [...tags]; return [...tags];
} }
} catch (e) { } catch (e) {

View File

@@ -51,7 +51,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich" applicationId "app.alextran.immich"
minSdkVersion 20 minSdkVersion 21
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
@@ -81,4 +81,5 @@ flutter {
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:multidex:1.0.3'
} }

View File

@@ -20,4 +20,7 @@
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <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> </manifest>

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1 @@
* Added inline font, remove google-font dependency in pubspec.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 KiB

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -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

Binary file not shown.

Binary file not shown.

BIN
mobile/fonts/WorkSans.ttf Normal file

Binary file not shown.

View File

@@ -13,7 +13,7 @@ PODS:
- Flutter - Flutter
- path_provider_ios (0.0.1): - path_provider_ios (0.0.1):
- Flutter - Flutter
- photo_manager (1.0.0): - photo_manager (2.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- SAMKeychain (1.5.3) - SAMKeychain (1.5.3)
@@ -70,7 +70,7 @@ SPEC CHECKSUMS:
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196

View File

@@ -1,66 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Immich</string> <string>Immich</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>immich_mobile</string> <string>immich_mobile</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>2</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/> <true />
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true />
</dict> </dict>
<key>NSLocationAlwaysUsageDescription</key> <key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string> <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>NSLocationWhenInUseUsageDescription</key>
<key>NSPhotoLibraryUsageDescription</key> <string>Enable location setting to show position of assets on map</string>
<string>We need to manage backup your photos album</string>
<key>UILaunchStoryboardName</key> <key>NSPhotoLibraryUsageDescription</key>
<string>LaunchScreen</string> <string>We need to manage backup your photos album</string>
<key>UIMainStoryboardFile</key>
<string>Main</string> <key>NSPhotoLibraryAddUsageDescription</key>
<key>UISupportedInterfaceOrientations</key> <string>We need to manage backup your photos album</string>
<array>
<string>UIInterfaceOrientationPortrait</string> <key>UILaunchStoryboardName</key>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>LaunchScreen</string>
<string>UIInterfaceOrientationLandscapeRight</string> <key>UIMainStoryboardFile</key>
</array> <string>Main</string>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationLandscapeRight</string> </array>
</array> <key>UISupportedInterfaceOrientations~ipad</key>
<key>UIUserInterfaceStyle</key> <array>
<string>Light</string> <string>UIInterfaceOrientationPortrait</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<true/> <string>UIInterfaceOrientationLandscapeLeft</string>
<key>io.flutter.embedded_views_preview</key> <string>UIInterfaceOrientationLandscapeRight</string>
<true/> </array>
<key>ITSAppUsesNonExemptEncryption</key> <key>UIUserInterfaceStyle</key>
<false/> <string>Light</string>
</dict> <key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
</dict>
</plist> </plist>

View File

@@ -19,11 +19,11 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( 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", build_app(scheme: "Runner",
workspace: "Runner.xcworkspace", workspace: "Runner.xcworkspace",
xcargs: "-allowProvisioningUpdates") xcargs: "-allowProvisioningUpdates")

View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
const immichBackgroundColor = Color(0xFFf6f8fe);

View File

@@ -2,21 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
import 'package:google_fonts/google_fonts.dart';
void main() async { void main() async {
await Hive.initFlutter(); await Hive.initFlutter();
await Hive.openBox(userInfoBox); await Hive.openBox(userInfoBox);
// Hive.registerAdapter(ImmichBackUpAssetAdapter());
// Hive.deleteBoxFromDisk(hiveImmichBox);
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(
@@ -88,26 +87,33 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp.router( return MaterialApp(
title: 'Immich',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( home: Stack(
brightness: Brightness.light, children: [
primarySwatch: Colors.indigo, MaterialApp.router(
textTheme: GoogleFonts.workSansTextTheme( title: 'Immich',
Theme.of(context).textTheme.apply(fontSizeFactor: 1.0), debugShowCheckedModeBanner: false,
), theme: ThemeData(
scaffoldBackgroundColor: const Color(0xFFf6f8fe), brightness: Brightness.light,
appBarTheme: const AppBarTheme( primarySwatch: Colors.indigo,
backgroundColor: Colors.white, fontFamily: 'WorkSans',
foregroundColor: Colors.indigo, snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
elevation: 1, scaffoldBackgroundColor: immichBackgroundColor,
centerTitle: true, appBarTheme: const AppBarTheme(
systemOverlayStyle: SystemUiOverlayStyle.dark, 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)]),
); );
} }
} }

View File

@@ -1,28 +1,34 @@
import 'dart:convert'; import 'dart:convert';
enum DownloadAssetStatus { idle, loading, success, error }
class ImageViewerPageState { class ImageViewerPageState {
final bool isBottomSheetEnable; // enum
final DownloadAssetStatus downloadAssetStatus;
ImageViewerPageState({ ImageViewerPageState({
required this.isBottomSheetEnable, required this.downloadAssetStatus,
}); });
ImageViewerPageState copyWith({ ImageViewerPageState copyWith({
bool? isBottomSheetEnable, DownloadAssetStatus? downloadAssetStatus,
}) { }) {
return ImageViewerPageState( return ImageViewerPageState(
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable, downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { final result = <String, dynamic>{};
'isBottomSheetEnable': isBottomSheetEnable,
}; result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
return result;
} }
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) { factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
return ImageViewerPageState( 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)); factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
@override @override
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)'; String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable; return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
} }
@override @override
int get hashCode => isBottomSheetEnable.hashCode; int get hashCode => downloadAssetStatus.hashCode;
} }

View File

@@ -0,0 +1,6 @@
class RequestDownloadAssetInfo {
final String assetId;
final String deviceId;
RequestDownloadAssetInfo(this.assetId, this.deviceId);
}

View File

@@ -1,21 +1,43 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/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/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> { class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false)); final ImageViewerService _imageViewerService = ImageViewerService();
void toggleBottomSheet() { ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
bool isBottomSheetEnable = state.isBottomSheetEnable;
if (isBottomSheetEnable) { void downloadAsset(ImmichAsset asset, BuildContext context) async {
state.copyWith(isBottomSheetEnable: false); 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 { } 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>( final imageViewerStateProvider =
((ref) => ImageViewerPageStateNotifier())); StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));

View File

@@ -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;
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -1,14 +1,19 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget { class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key); const TopControlAppBar(
{Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
: super(key: key);
final ImmichAsset asset; final ImmichAsset asset;
final Function onMoreInfoPressed; final Function onMoreInfoPressed;
final Function onDownloadPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
double iconSize = 18.0; double iconSize = 18.0;
return AppBar( return AppBar(
@@ -29,7 +34,7 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
onPressed: () { onPressed: () {
print("download"); onDownloadPressed();
}, },
icon: const Icon(Icons.cloud_download_rounded), icon: const Icon(Icons.cloud_download_rounded),
), ),

View File

@@ -1,9 +1,14 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
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/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/modules/home/services/asset.service.dart';
@@ -25,12 +30,25 @@ class ImageViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
getAssetExif() async { getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id); assetDetail = await _assetService.getAssetById(asset.id);
} }
showInfo() {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
useEffect(() { useEffect(() {
getAssetExif(); getAssetExif();
return null; return null;
@@ -40,66 +58,76 @@ class ImageViewerPage extends HookConsumerWidget {
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: TopControlAppBar( appBar: TopControlAppBar(
asset: asset, asset: asset,
onMoreInfoPressed: () { onMoreInfoPressed: showInfo,
showModalBottomSheet( onDownloadPressed: () {
backgroundColor: Colors.black, ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
}, },
), ),
body: SafeArea( body: SwipeDetector(
child: Center( onSwipeDown: (_) {
child: Hero( AutoRouter.of(context).pop();
tag: heroTag, },
child: CachedNetworkImage( onSwipeUp: (_) {
fit: BoxFit.cover, showInfo();
imageUrl: imageUrl, },
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, child: SafeArea(
fadeInDuration: const Duration(milliseconds: 250), child: Stack(
errorWidget: (context, url, error) => ConstrainedBox( children: [
constraints: const BoxConstraints(maxWidth: 300), Center(
child: Wrap( child: Hero(
spacing: 32, tag: heroTag,
runSpacing: 32, child: CachedNetworkImage(
alignment: WrapAlignment.center, fit: BoxFit.cover,
children: [ imageUrl: imageUrl,
const Text( httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
"Failed To Render Image - Possibly Corrupted Data", fadeInDuration: const Duration(milliseconds: 250),
textAlign: TextAlign.center, errorWidget: (context, url, error) => ConstrainedBox(
style: TextStyle(fontSize: 16, color: Colors.white), constraints: const BoxConstraints(maxWidth: 300),
), child: Wrap(
SingleChildScrollView( spacing: 32,
child: Text( runSpacing: 32,
error.toString(), alignment: WrapAlignment.center,
textAlign: TextAlign.center, children: [
style: TextStyle(fontSize: 12, color: Colors.grey[400]), 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) { if (downloadAssetStatus == DownloadAssetStatus.loading)
return CachedNetworkImage( const Center(
cacheKey: thumbnailUrl, child: DownloadLoadingIndicator(),
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],
),
);
},
),
), ),
), ),
), ),

View 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,
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart'; import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DeleteDialog extends ConsumerWidget { class DeleteDialog extends ConsumerWidget {

View File

@@ -11,40 +11,44 @@ class ImageGrid extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid( return SliverGrid(
gridDelegate: gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5), crossAxisCount: 3,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
var assetType = assetGroup[index].type; var assetType = assetGroup[index].type;
return GestureDetector( return GestureDetector(
onTap: () {}, onTap: () {},
child: Stack( child: Stack(
children: [ children: [
ThumbnailImage(asset: assetGroup[index]), ThumbnailImage(asset: assetGroup[index]),
assetType == 'IMAGE' assetType == 'IMAGE'
? Container() ? Container()
: Positioned( : Positioned(
top: 5, top: 5,
right: 5, right: 5,
child: Row( child: Row(
children: [ children: [
Text( Text(
assetGroup[index].duration.toString().substring(0, 7), assetGroup[index].duration.toString().substring(0, 7),
style: const TextStyle( style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white, color: Colors.white,
fontSize: 10,
), ),
], ),
), const Icon(
) Icons.play_circle_outline_rounded,
], color: Colors.white,
)); ),
],
),
)
],
),
);
}, },
childCount: assetGroup.length, childCount: assetGroup.length,
), ),

View File

@@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:badges/badges.dart'; import 'package:badges/badges.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -30,7 +29,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
floating: true, floating: true,
pinned: false, pinned: false,
snap: false, snap: false,
backgroundColor: Colors.grey[200], // backgroundColor: Colors.grey[200],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
leading: Builder( leading: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
@@ -41,7 +40,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
child: IconButton( child: IconButton(
splashRadius: 25, splashRadius: 25,
icon: const Icon( icon: const Icon(
Icons.account_circle_rounded, Icons.face_outlined,
size: 30, size: 30,
), ),
onPressed: () { onPressed: () {
@@ -79,12 +78,11 @@ class ImmichSliverAppBar extends ConsumerWidget {
), ),
title: Text( title: Text(
'IMMICH', 'IMMICH',
style: GoogleFonts.snowburstOne( style: TextStyle(
textStyle: TextStyle( fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 22, fontSize: 22,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
),
), ),
), ),
actions: [ actions: [
@@ -121,8 +119,12 @@ class ImmichSliverAppBar extends ConsumerWidget {
), ),
child: const Icon(Icons.backup_rounded)), child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller', tooltip: 'Backup Controller',
onPressed: () { onPressed: () async {
AutoRouter.of(context).push(const BackupControllerRoute()); var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop != null && onPop == true) {
onPopBack!();
}
}, },
), ),
_backupState.backupProgress == BackUpProgressEnum.inProgress _backupState.backupProgress == BackUpProgressEnum.inProgress

View File

@@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart'; import 'package:immich_mobile/shared/models/server_info_state.model.dart';
@@ -79,7 +79,7 @@ class ProfileDrawer extends HookConsumerWidget {
), ),
title: const Text( title: const Text(
"Sign Out", "Sign Out",
style: TextStyle(color: Colors.black54, fontSize: 14), style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold),
), ),
onTap: () async { onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout(); bool res = await ref.read(authenticationProvider.notifier).logout();

View File

@@ -65,8 +65,8 @@ class ThumbnailImage extends HookConsumerWidget {
} else { } else {
AutoRouter.of(context).push( AutoRouter.of(context).push(
VideoViewerRoute( 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),
); );
} }
} }

View File

@@ -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/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
@@ -33,6 +33,10 @@ class HomePage extends HookConsumerWidget {
return null; return null;
}, []); }, []);
void reloadAllAsset() {
ref.read(assetProvider.notifier).getAllAsset();
}
Widget _buildBody() { Widget _buildBody() {
if (assetGroupByDateTime.isNotEmpty) { if (assetGroupByDateTime.isNotEmpty) {
int? lastMonth; int? lastMonth;
@@ -86,7 +90,9 @@ class HomePage extends HookConsumerWidget {
child: null, child: null,
), ),
) )
: const ImmichSliverAppBar(), : ImmichSliverAppBar(
onPopBack: reloadAllAsset,
),
duration: const Duration(milliseconds: 350), duration: const Duration(milliseconds: 350),
), ),
..._imageGridGroup ..._imageGridGroup

View File

@@ -1,9 +1,8 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -33,9 +32,12 @@ class LoginForm extends HookConsumerWidget {
), ),
Text( Text(
'IMMICH', 'IMMICH',
style: GoogleFonts.snowburstOne( style: TextStyle(
textStyle: fontFamily: 'SnowburstOne',
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)), fontWeight: FontWeight.bold,
fontSize: 48,
color: Theme.of(context).primaryColor,
),
), ),
EmailInput(controller: usernameController), EmailInput(controller: usernameController),
PasswordInput(controller: passwordController), PasswordInput(controller: passwordController),
@@ -117,20 +119,21 @@ class LoginButton extends ConsumerWidget {
// This will remove current cache asset state of previous user login. // This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset(); ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenicated = await ref var isAuthenticated = await ref
.read(authenticationProvider.notifier) .read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text); .login(emailController.text, passwordController.text, serverEndpointController.text);
if (isAuthenicated) { if (isAuthenticated) {
// Resume backup (if enable) then navigate // Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(backupProvider.notifier).resumeBackup();
// AutoRouter.of(context).pushNamed("/home-page"); // AutoRouter.of(context).pushNamed("/home-page");
AutoRouter.of(context).pushNamed("/tab-controller-page"); AutoRouter.of(context).pushNamed("/tab-controller-page");
} else { } else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Error logging you in, check server url, email and password!", msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error); toastType: ToastType.error,
);
} }
}, },
child: const Text("Login")); child: const Text("Login"));

View 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,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
@@ -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/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.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/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/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart'; import 'package:immich_mobile/utils/capitalize_first_letter.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
@@ -23,8 +25,10 @@ class SearchPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider); AsyncValue<List<CuratedLocation>> curatedLocation =
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider); ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObject>> curatedObjects =
ref.watch(getCuratedObjectProvider);
useEffect(() { useEffect(() {
searchFocusNode = FocusNode(); searchFocusNode = FocusNode();
@@ -40,12 +44,15 @@ class SearchPage extends HookConsumerWidget {
_buildPlaces() { _buildPlaces() {
return curatedLocation.when( return curatedLocation.when(
loading: () => const CircularProgressIndicator(), loading: () => const SizedBox(
height: 200,
child: Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'), error: (err, stack) => Text('Error: $err'),
data: (curatedLocations) { data: (curatedLocations) {
return curatedLocations.isNotEmpty return curatedLocations.isNotEmpty
? SizedBox( ? SizedBox(
height: MediaQuery.of(context).size.width / 3, height: MediaQuery.of(context).size.width / 2,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -59,14 +66,15 @@ class SearchPage extends HookConsumerWidget {
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
textInfo: locationInfo.city, textInfo: locationInfo.city,
onTap: () { onTap: () {
AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city)); AutoRouter.of(context).push(
SearchResultRoute(searchTerm: locationInfo.city));
}, },
); );
}), }),
), ),
) )
: SizedBox( : SizedBox(
height: MediaQuery.of(context).size.width / 3, height: MediaQuery.of(context).size.width / 2,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -87,12 +95,15 @@ class SearchPage extends HookConsumerWidget {
_buildThings() { _buildThings() {
return curatedObjects.when( return curatedObjects.when(
loading: () => const CircularProgressIndicator(), loading: () => const SizedBox(
height: 200,
child: Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'), error: (err, stack) => Text('Error: $err'),
data: (objects) { data: (objects) {
return objects.isNotEmpty return objects.isNotEmpty
? SizedBox( ? SizedBox(
height: MediaQuery.of(context).size.width / 3, height: MediaQuery.of(context).size.width / 2,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -106,15 +117,16 @@ class SearchPage extends HookConsumerWidget {
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
textInfo: curatedObjectInfo.object, textInfo: curatedObjectInfo.object,
onTap: () { onTap: () {
AutoRouter.of(context) AutoRouter.of(context).push(SearchResultRoute(
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter())); searchTerm: curatedObjectInfo.object
.capitalizeFirstLetter()));
}, },
); );
}), }),
), ),
) )
: SizedBox( : SizedBox(
height: MediaQuery.of(context).size.width / 3, height: MediaQuery.of(context).size.width / 2,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -165,73 +177,12 @@ class SearchPage extends HookConsumerWidget {
_buildThings() _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,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
@@ -107,7 +108,10 @@ class SearchResultPage extends HookConsumerWidget {
} }
if (searchResultPageState.isLoading) { if (searchResultPageState.isLoading) {
return const CircularProgressIndicator.adaptive(); return Center(
child: SpinKitDancingSquare(
color: Theme.of(context).primaryColor,
));
} }
if (searchResultPageState.isSuccess) { if (searchResultPageState.isSuccess) {

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View File

@@ -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());

View File

@@ -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();
});

View File

@@ -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);
});

View File

@@ -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();
});

View 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;
}
}
}

View File

@@ -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,
),
);
}
}

View 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,
),
);
}
}

View 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);
}

View 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(),
],
),
),
);
}
}

View 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,
),
);
}
}

View 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,
),
),
),
],
),
),
);
}
}

View 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,
),
],
),
)
],
),
),
);
}
}

View File

@@ -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,
);
},
),
],
),
),
);
}
}

View 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),
),
),
),
)
],
),
),
),
);
}
}

View 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(),
),
),
);
}
}

View 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(),
);
}
}

View 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(),
],
),
));
}
}

View File

@@ -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(),
),
),
);
}
}

View File

@@ -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(),
),
),
);
}
}

View 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()
],
),
);
}
}

View File

@@ -1,15 +1,23 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart'; import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart'; import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/sharing/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/modules/sharing/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/sharing/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/sharing/views/create_shared_album_page.dart';
import 'package:immich_mobile/modules/sharing/views/select_additional_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/sharing/views/sharing_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/shared/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
part 'router.gr.dart'; part 'router.gr.dart';
@@ -22,13 +30,31 @@ part 'router.gr.dart';
guards: [AuthGuard], guards: [AuthGuard],
children: [ children: [
AutoRoute(page: HomePage, guards: [AuthGuard]), AutoRoute(page: HomePage, guards: [AuthGuard]),
AutoRoute(page: SearchPage, guards: [AuthGuard]) AutoRoute(page: SearchPage, guards: [AuthGuard]),
AutoRoute(page: SharingPage, guards: [AuthGuard])
], ],
), ),
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard]), AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
AutoRoute(page: CreateSharedAlbumPage, guards: [AuthGuard]),
CustomRoute<AssetSelectionPageResult?>(
page: AssetSelectionPage,
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
CustomRoute<List<String>>(
page: SelectUserForSharingPage,
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: AlbumViewerPage, guards: [AuthGuard]),
CustomRoute<List<String>?>(
page: SelectAdditionalUserForSharingPage,
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
], ],
) )
class AppRouter extends _$AppRouter { class AppRouter extends _$AppRouter {

View File

@@ -44,7 +44,8 @@ class _$AppRouter extends RootStackRouter {
final args = routeData.argsAs<VideoViewerRouteArgs>(); final args = routeData.argsAs<VideoViewerRouteArgs>();
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, routeData: routeData,
child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl)); child: VideoViewerPage(
key: args.key, videoUrl: args.videoUrl, asset: args.asset));
}, },
BackupControllerRoute.name: (routeData) { BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
@@ -56,6 +57,42 @@ class _$AppRouter extends RootStackRouter {
routeData: routeData, routeData: routeData,
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm)); child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
}, },
CreateSharedAlbumRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const CreateSharedAlbumPage());
},
AssetSelectionRoute.name: (routeData) {
return CustomPage<AssetSelectionPageResult?>(
routeData: routeData,
child: const AssetSelectionPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
SelectUserForSharingRoute.name: (routeData) {
return CustomPage<List<String>>(
routeData: routeData,
child: const SelectUserForSharingPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
AlbumViewerRoute.name: (routeData) {
final args = routeData.argsAs<AlbumViewerRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: AlbumViewerPage(key: args.key, albumId: args.albumId));
},
SelectAdditionalUserForSharingRoute.name: (routeData) {
final args = routeData.argsAs<SelectAdditionalUserForSharingRouteArgs>();
return CustomPage<List<String>?>(
routeData: routeData,
child: SelectAdditionalUserForSharingPage(
key: args.key, albumInfo: args.albumInfo),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
HomeRoute.name: (routeData) { HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage()); routeData: routeData, child: const HomePage());
@@ -65,6 +102,10 @@ class _$AppRouter extends RootStackRouter {
orElse: () => const SearchRouteArgs()); orElse: () => const SearchRouteArgs());
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: SearchPage(key: args.key)); routeData: routeData, child: SearchPage(key: args.key));
},
SharingRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const SharingPage());
} }
}; };
@@ -84,6 +125,10 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(SearchRoute.name, RouteConfig(SearchRoute.name,
path: 'search-page', path: 'search-page',
parent: TabControllerRoute.name, parent: TabControllerRoute.name,
guards: [authGuard]),
RouteConfig(SharingRoute.name,
path: 'sharing-page',
parent: TabControllerRoute.name,
guards: [authGuard]) guards: [authGuard])
]), ]),
RouteConfig(ImageViewerRoute.name, RouteConfig(ImageViewerRoute.name,
@@ -93,7 +138,18 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(BackupControllerRoute.name, RouteConfig(BackupControllerRoute.name,
path: '/backup-controller-page', guards: [authGuard]), path: '/backup-controller-page', guards: [authGuard]),
RouteConfig(SearchResultRoute.name, RouteConfig(SearchResultRoute.name,
path: '/search-result-page', guards: [authGuard]) path: '/search-result-page', guards: [authGuard]),
RouteConfig(CreateSharedAlbumRoute.name,
path: '/create-shared-album-page', guards: [authGuard]),
RouteConfig(AssetSelectionRoute.name,
path: '/asset-selection-page', guards: [authGuard]),
RouteConfig(SelectUserForSharingRoute.name,
path: '/select-user-for-sharing-page', guards: [authGuard]),
RouteConfig(AlbumViewerRoute.name,
path: '/album-viewer-page', guards: [authGuard]),
RouteConfig(SelectAdditionalUserForSharingRoute.name,
path: '/select-additional-user-for-sharing-page',
guards: [authGuard])
]; ];
} }
@@ -163,24 +219,29 @@ class ImageViewerRouteArgs {
/// generated route for /// generated route for
/// [VideoViewerPage] /// [VideoViewerPage]
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> { class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
VideoViewerRoute({Key? key, required String videoUrl}) VideoViewerRoute(
{Key? key, required String videoUrl, required ImmichAsset asset})
: super(VideoViewerRoute.name, : super(VideoViewerRoute.name,
path: '/video-viewer-page', path: '/video-viewer-page',
args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl)); args: VideoViewerRouteArgs(
key: key, videoUrl: videoUrl, asset: asset));
static const String name = 'VideoViewerRoute'; static const String name = 'VideoViewerRoute';
} }
class VideoViewerRouteArgs { class VideoViewerRouteArgs {
const VideoViewerRouteArgs({this.key, required this.videoUrl}); const VideoViewerRouteArgs(
{this.key, required this.videoUrl, required this.asset});
final Key? key; final Key? key;
final String videoUrl; final String videoUrl;
final ImmichAsset asset;
@override @override
String toString() { 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 /// generated route for
/// [HomePage] /// [HomePage]
class HomeRoute extends PageRouteInfo<void> { class HomeRoute extends PageRouteInfo<void> {
@@ -245,3 +386,11 @@ class SearchRouteArgs {
return 'SearchRouteArgs{key: $key}'; return 'SearchRouteArgs{key: $key}';
} }
} }
/// generated route for
/// [SharingPage]
class SharingRoute extends PageRouteInfo<void> {
const SharingRoute() : super(SharingRoute.name, path: 'sharing-page');
static const String name = 'SharingRoute';
}

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class TabNavigationObserver extends AutoRouterObserver { class TabNavigationObserver extends AutoRouterObserver {
@@ -21,7 +22,8 @@ class TabNavigationObserver extends AutoRouterObserver {
} }
@override @override
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { Future<void> didChangeTabRoute(
TabPageRoute route, TabPageRoute previousRoute) async {
// Perform tasks on re-visit to SearchRoute // Perform tasks on re-visit to SearchRoute
if (route.name == 'SearchRoute') { if (route.name == 'SearchRoute') {
// Refresh Location State // Refresh Location State
@@ -29,6 +31,10 @@ class TabNavigationObserver extends AutoRouterObserver {
ref.refresh(getCuratedObjectProvider); ref.refresh(getCuratedObjectProvider);
} }
if (route.name == 'SharingRoute') {
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
}
ref.watch(serverInfoProvider.notifier).getServerVersion(); ref.watch(serverInfoProvider.notifier).getServerVersion();
} }
} }

View File

@@ -10,6 +10,8 @@ class ImmichAsset {
final String modifiedAt; final String modifiedAt;
final bool isFavorite; final bool isFavorite;
final String? duration; final String? duration;
final String originalPath;
final String resizePath;
ImmichAsset({ ImmichAsset({
required this.id, required this.id,
@@ -21,6 +23,8 @@ class ImmichAsset {
required this.modifiedAt, required this.modifiedAt,
required this.isFavorite, required this.isFavorite,
this.duration, this.duration,
required this.originalPath,
required this.resizePath,
}); });
ImmichAsset copyWith({ ImmichAsset copyWith({
@@ -33,6 +37,8 @@ class ImmichAsset {
String? modifiedAt, String? modifiedAt,
bool? isFavorite, bool? isFavorite,
String? duration, String? duration,
String? originalPath,
String? resizePath,
}) { }) {
return ImmichAsset( return ImmichAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -44,6 +50,8 @@ class ImmichAsset {
modifiedAt: modifiedAt ?? this.modifiedAt, modifiedAt: modifiedAt ?? this.modifiedAt,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
duration: duration ?? this.duration, duration: duration ?? this.duration,
originalPath: originalPath ?? this.originalPath,
resizePath: resizePath ?? this.resizePath,
); );
} }
@@ -58,6 +66,8 @@ class ImmichAsset {
'modifiedAt': modifiedAt, 'modifiedAt': modifiedAt,
'isFavorite': isFavorite, 'isFavorite': isFavorite,
'duration': duration, 'duration': duration,
'originalPath': originalPath,
'resizePath': resizePath,
}; };
} }
@@ -72,6 +82,8 @@ class ImmichAsset {
modifiedAt: map['modifiedAt'] ?? '', modifiedAt: map['modifiedAt'] ?? '',
isFavorite: map['isFavorite'] ?? false, isFavorite: map['isFavorite'] ?? false,
duration: map['duration'], duration: map['duration'],
originalPath: map['originalPath'] ?? '',
resizePath: map['resizePath'] ?? '',
); );
} }
@@ -81,7 +93,7 @@ class ImmichAsset {
@override @override
String toString() { 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 @override
@@ -97,7 +109,9 @@ class ImmichAsset {
other.createdAt == createdAt && other.createdAt == createdAt &&
other.modifiedAt == modifiedAt && other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.duration == duration; other.duration == duration &&
other.originalPath == originalPath &&
other.resizePath == resizePath;
} }
@override @override
@@ -110,6 +124,8 @@ class ImmichAsset {
createdAt.hashCode ^ createdAt.hashCode ^
modifiedAt.hashCode ^ modifiedAt.hashCode ^
isFavorite.hashCode ^ isFavorite.hashCode ^
duration.hashCode; duration.hashCode ^
originalPath.hashCode ^
resizePath.hashCode;
} }
} }

View 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;
}

View File

@@ -72,3 +72,11 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a)); assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt))); return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
}); });
final assetGroupByMonthYearProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) => DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)));
});

View File

@@ -37,24 +37,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Ref? ref; Ref? ref;
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService(); final ServerInfoService _serverInfoService = ServerInfoService();
final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast(); final StreamController _onAssetBackupStreamCtrl =
StreamController.broadcast();
void getBackupInfo() async { void getBackupInfo() async {
_updateServerInfo(); _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(); List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
if (list.isEmpty) { if (list.isEmpty) {
debugPrint("No Asset On Device"); debugPrint("No Asset On Device");
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length); backupProgress: BackUpProgressEnum.idle,
totalAssetCount: 0,
assetOnDatabase: didBackupAsset.length);
return; return;
} }
int totalAsset = list[0].assetCount; int totalAsset = list[0].assetCount;
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length); state = state.copyWith(
totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
} }
void startBackupProcess() async { void startBackupProcess() async {
@@ -67,8 +72,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
// await PhotoManager.presentLimited(); // await PhotoManager.presentLimited();
// Gather assets info // Gather assets info
List<AssetPathEntity> list = List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common); hasAll: true, onlyAll: true, type: RequestType.common);
// Get device assets info from database // Get device assets info from database
// Compare and find different assets that has not been backing up // Compare and find different assets that has not been backing up
@@ -78,14 +83,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (list.isEmpty) { if (list.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process"); debugPrint("No Asset On Device - Abort Backup Process");
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: backupAsset.length); backupProgress: BackUpProgressEnum.idle,
totalAssetCount: 0,
assetOnDatabase: backupAsset.length);
return; return;
} }
int totalAsset = list[0].assetCount; 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 // Remove item that has already been backed up
for (var backupAssetId in backupAsset) { for (var backupAssetId in backupAsset) {
currentAssets.removeWhere((e) => e.id == backupAssetId); currentAssets.removeWhere((e) => e.id == backupAssetId);
@@ -97,9 +106,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(backingUpAssetCount: currentAssets.length); state = state.copyWith(backingUpAssetCount: currentAssets.length);
// Perform Packup // Perform Backup
state = state.copyWith(cancelToken: CancelToken()); state = state.copyWith(cancelToken: CancelToken());
_backupService.backupAsset(currentAssets, state.cancelToken, _onAssetUploaded, _onUploadProgress); _backupService.backupAsset(currentAssets, state.cancelToken,
_onAssetUploaded, _onUploadProgress);
} else { } else {
PhotoManager.openSetting(); PhotoManager.openSetting();
} }
@@ -107,22 +117,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel('Cancel Backup'); 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) { void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state = state.copyWith(
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1); backingUpAssetCount: state.backingUpAssetCount - 1,
assetOnDatabase: state.assetOnDatabase + 1);
if (state.backingUpAssetCount == 0) { if (state.backingUpAssetCount == 0) {
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0); state = state.copyWith(
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
} }
_updateServerInfo(); _updateServerInfo();
} }
void _onUploadProgress(int sent, int total) { 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 { void _updateServerInfo() async {
@@ -156,7 +170,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
// Check if this device is enable backup by the user // 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 // check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) { if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort"); 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); return BackupNotifier(ref: ref);
}); });

View File

@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:socket_io_client/socket_io_client.dart'; import 'package:socket_io_client/socket_io_client.dart';
@@ -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) { final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {

View File

@@ -73,7 +73,7 @@ class BackupService {
}); });
// Build thumbnail multipart data // Build thumbnail multipart data
var thumbnailData = await entity.thumbDataWithSize(1280, 720); var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280));
if (thumbnailData != null) { if (thumbnailData != null) {
thumbnailUploadData = MultipartFile.fromBytes( thumbnailUploadData = MultipartFile.fromBytes(
List.from(thumbnailData), List.from(thumbnailData),
@@ -112,7 +112,10 @@ class BackupService {
} }
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("DioError backupAsset: ${e.response}"); debugPrint("DioError backupAsset: ${e.response}");
break; if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) {
return;
}
continue;
} catch (e) { } catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}"); debugPrint("ERROR backupAsset: ${e.toString()}");
continue; continue;

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; 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 { try {
var dio = Dio(); var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor()); dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Response res = await dio.get('$savedEndpoint/$url');
if (res.statusCode == 200) { if (isByteResponse) {
return res; 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) { } on DioError catch (e) {
debugPrint("DioError: ${e.response}"); debugPrint("DioError: ${e.response}");
@@ -55,9 +76,10 @@ class NetworkService {
return res; return res;
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("DioError: ${e.response}"); debugPrint("DioError: ${e.response}");
return false; return null;
} catch (e) { } catch (e) {
debugPrint("ERROR BackupService: $e"); debugPrint("ERROR BackupService: $e");
return null;
} }
} }

View 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 [];
}
}

View 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,
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -8,12 +8,24 @@ class ImmichToast {
required BuildContext context, required BuildContext context,
required String msg, required String msg,
ToastType toastType = ToastType.info, ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.TOP,
}) { }) {
FToast fToast; FToast fToast;
fToast = FToast(); fToast = FToast();
fToast.init(context); 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( fToast.showToast(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
@@ -36,8 +48,8 @@ class ImmichToast {
: Container(), : Container(),
(toastType == ToastType.success) (toastType == ToastType.success)
? const Icon( ? const Icon(
Icons.check, Icons.check_circle_rounded,
color: Color.fromARGB(255, 104, 248, 140), color: Color.fromARGB(255, 78, 140, 124),
) )
: Container(), : Container(),
(toastType == ToastType.error) (toastType == ToastType.error)
@@ -53,7 +65,7 @@ class ImmichToast {
child: Text( child: Text(
msg, msg,
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: _getColor(toastType, context),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 15, fontSize: 15,
), ),
@@ -62,7 +74,7 @@ class ImmichToast {
], ],
), ),
), ),
gravity: ToastGravity.TOP, gravity: gravity,
toastDuration: const Duration(seconds: 2), toastDuration: const Duration(seconds: 2),
); );
} }

View File

@@ -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/shared/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart';
class BackupControllerPage extends HookConsumerWidget { class BackupControllerPage extends HookConsumerWidget {
@@ -23,6 +24,7 @@ class BackupControllerPage extends HookConsumerWidget {
ref.read(backupProvider.notifier).getBackupInfo(); ref.read(backupProvider.notifier).getBackupInfo();
} }
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
return null; return null;
}, []); }, []);
@@ -85,13 +87,16 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontSize: 14), style: TextStyle(fontSize: 14),
) )
: Container(), : Container(),
OutlinedButton( Padding(
onPressed: () { padding: const EdgeInsets.only(top: 8.0),
isAutoBackup child: OutlinedButton(
? ref.watch(authenticationProvider.notifier).setAutoBackup(false) onPressed: () {
: ref.watch(authenticationProvider.notifier).setAutoBackup(true); isAutoBackup
}, ? ref.watch(authenticationProvider.notifier).setAutoBackup(false)
child: Text("Turn $backupBtnText Backup"), : 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( leading: IconButton(
onPressed: () { onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
AutoRouter.of(context).pop(true); AutoRouter.of(context).pop(true);
}, },
icon: const Icon(Icons.arrow_back_ios_rounded)), icon: const Icon(Icons.arrow_back_ios_rounded)),

View 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;
}
}

View File

@@ -15,6 +15,7 @@ class TabControllerPage extends ConsumerWidget {
routes: [ routes: [
const HomeRoute(), const HomeRoute(),
SearchRoute(), SearchRoute(),
const SharingRoute(),
], ],
builder: (context, child, animation) { builder: (context, child, animation) {
final tabsRouter = AutoTabsRouter.of(context); final tabsRouter = AutoTabsRouter.of(context);
@@ -34,7 +35,8 @@ class TabControllerPage extends ConsumerWidget {
}, },
items: const [ items: const [
BottomNavigationBarItem(label: 'Photos', icon: Icon(Icons.photo)), 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)),
], ],
), ),
); );

View File

@@ -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,
),
);
}
}

View File

@@ -35,6 +35,9 @@ class FileHelper {
case 'dng': case 'dng':
return {"type": "image", "subType": "dng"}; return {"type": "image", "subType": "dng"};
case 'webp':
return {"type": "image", "subType": "webp"};
default: default:
return {"type": "unsupport", "subType": "unsupport"}; return {"type": "unsupport", "subType": "unsupport"};
} }

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