Compare commits

...

46 Commits

Author SHA1 Message Date
Alex
5990a28870 Implemented search result page (#37) 2022-03-02 16:44:24 -06:00
Alex Tran
bd34be92e6 resolve merge conflict 2022-02-27 12:49:18 -06:00
Alex Tran
e608c61ba5 Resolve merge conflict 2022-02-27 12:46:19 -06:00
Alex Tran
d2edc0bffe ok 2022-02-27 12:45:12 -06:00
Alex
bfde308492 Added Tab Bar, search bar and suggested search terms from assets metadata, tags (#35) 2022-02-27 12:43:29 -06:00
Alex
f181dba964 Refactor Python Modules (#29) 2022-02-20 21:17:36 -06:00
Alex Tran
c894e36855 update readme 2022-02-19 23:26:48 -06:00
Alex Tran
01e906e99c update readme 2022-02-19 23:25:55 -06:00
Alex Tran
352800223e remove GPU build on Python server 2022-02-19 22:57:11 -06:00
Alex
619735fea0 Implemented image tagging using TensorFlow InceptionV3 (#28)
* Refactor docker-compose to its own folder
* Added FastAPI development environment
* Added support for GPU in docker file
* Added image classification
* creating endpoint for smart Image info
* added logo with white background on ios
* Added endpoint and trigger for image tagging
* Classify image and save into database
* Update readme
2022-02-19 22:42:10 -06:00
Alex Tran
75b1ed08b4 Fixed problem of not disconnecting Websocket when logging out 2022-02-14 14:42:06 -06:00
Alex
c234c95880 websocket upload notification - closed #24 (#25)
* Render when a new asset is uploaded from WebSocket notification
* Update Readme
2022-02-14 10:40:41 -06:00
dependabot[bot]
7cc7fc0a0c Bump follow-redirects from 1.14.7 to 1.14.8 in /server (#23)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-13 15:12:51 -06:00
Alex
897d49f734 Implemented delete asset on device and on database (#22)
* refactor serving file function asset service
* Remove PhotoViewer for now since it creates a problem in 2.10
* Added error message for wrong decode file and logo for failed to load file
* Fixed error when read stream cannot be created and crash server
* Added method to get all assets as a raw array
* Implemented cleaner way of grouping image
* Implemented operation to delete assets in the database
* Implemented delete on database operation
* Implemented delete on device operation
* Fixed issue display wrong information when the auto backup is enabled after deleting all assets
2022-02-13 15:10:42 -06:00
Alex Tran
051c958c8b Update Readme 2022-02-11 22:35:14 -06:00
Alex Tran
56627caf5b Fixed EACCES permission when mounting bind volume, add dynamic location for backup directory 2022-02-11 22:23:06 -06:00
Tran, Alex
4f47c8c06b Update readme 2022-02-10 20:42:22 -06:00
Alex
de1dbcea9c Implemented EXIF store and display (#19)
* Added EXIF extracting in the backend
* Added EXIF displaying on `image_viewer_page.dart`
* Added Icon for backup option not enable
2022-02-10 20:40:11 -06:00
Alex Tran
d1498506a8 Remove TensorFlow dependency to work with ARM64 2022-02-09 21:06:37 -06:00
Alex Tran
9bcbdd31ce Added arm64 to github action for docker build 2022-02-09 20:52:38 -06:00
Alex
38c968d47e Support HEIC/HEIF (#16)
* Support HEIC/HEIF backup
* Storing backup directly from original file from the phone
* Directly read and backup video file - Improve performance on video backup
2022-02-09 20:48:06 -06:00
Alex
f578ca6d47 Implemented bottom app bar with control buttons for asset's operation (#15) 2022-02-09 12:41:02 -06:00
Alex Tran
b04e69fd66 Update readme with screenshots 2022-02-08 14:18:51 -06:00
Alex Tran
0a1e28a08f Update readme 2022-02-08 14:06:58 -06:00
Alex Tran
9fdaa82d77 Update readme with gif 2022-02-08 14:05:31 -06:00
Alex Tran
88123b1cd2 Update readme with gif 2022-02-08 13:59:25 -06:00
Alex Tran
42c4c9dba1 Update readme with gif 2022-02-08 13:56:37 -06:00
Alex Tran
e63dc49475 Update readme with gif 2022-02-08 13:55:18 -06:00
Alex Tran
690f30f3dd Update Artifact path 2022-02-08 11:57:49 -06:00
Alex Tran
561b030e80 Update github action 2022-02-08 11:43:38 -06:00
Alex Tran
4756c075b6 Added work flow to build APK on push to master 2022-02-08 11:36:43 -06:00
Alex
328f382f86 Implemented multi select interaction (#13) 2022-02-08 11:24:49 -06:00
Alex Tran
6ad77e9434 Update readme 2022-02-07 23:55:30 -06:00
Alex
919928ab70 Implemented auto backup (#11) 2022-02-07 23:42:35 -06:00
Alex Tran
2a4d4ea999 Change docker hub name to the correct one 2022-02-07 16:20:21 -06:00
Alex Tran
547ce49500 Remove armv7-64bit for docker build as Tensorflow doesn't support that architecture, add amd64 2022-02-07 15:52:42 -06:00
Alex Tran
f4970ed053 Update readme 2022-02-07 15:44:03 -06:00
Alex Tran
9cf083decf Update readme 2022-02-07 15:25:51 -06:00
Alex Tran
d078367c04 change path in of target docker file in docker-compose for server 2022-02-07 15:11:59 -06:00
Alex Tran
a8edc85183 rename docker-minimal to dockerfile as target for github action 2022-02-07 15:06:30 -06:00
Alex Tran
5d48de7fa9 Change to npm instead of yarn in docker image to test for build error on github action 2022-02-07 14:58:23 -06:00
Alex Tran
82beb040bc Remove production build on docker file to test build for arm architecture 2022-02-07 14:38:02 -06:00
Alex Tran
03864e52ff Enable automated dockerhub image build 2022-02-07 08:55:15 -06:00
Alex
c24fb403c5 Implemented load new image when navigating back from backup page (#9) 2022-02-06 20:31:32 -06:00
Alex
1d3ee2008c Update workflow to build on pull request only 2022-02-06 13:11:17 -06:00
schklom
c917875943 Automated multi-platform build and DockerHub publication (#8)
* Automated multi-arch build

This setup uses GitHub Actions to build an image for arm/v7 and arm64 then publish them on DockerHub (you need to setup repo secrets first) every time you want (workflow_dispatch), every push, every pull requests (pull_requests), or on a schedule (cronjob) :)

Remove the triggers you don't want.

Reminder: if you ever move the Dockerfile (or some dependencies), you will have to correct the Dockerfile path (and/or the context path).

* Create dependabot.yml

This checks the dependencies' versions for the Actions everyday and creates a pull request if there are new versions available.
2022-02-06 13:06:01 -06:00
147 changed files with 13173 additions and 7541 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

46
.github/workflows/Build+push Immich.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Build+push Immich
on:
# Triggers the workflow on push or pull request events but only for the main branch
#schedule:
# * is a special character in YAML so you have to quote this string
#- cron: '0 0 * * *'
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
buildandpush:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2.4.0
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.9.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

32
.github/workflows/build_apk.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Build APK Android
on:
workflow_dispatch:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./mobile
steps:
- uses: actions/checkout@v2
# Build
- uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
cache-key: flutter2.10 # optional, change this to force refresh cache
- run: flutter --version
- run: flutter pub get
- run: flutter build apk
- run: flutter build appbundle
# Upload Artifact
- uses: actions/upload-artifact@v2
with:
name: release-apk
path: mobile/build/app/outputs/apk/release/app-release.apk

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2022 Alex Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,5 +1,8 @@
dev: dev:
docker-compose -f ./server/docker-compose.yml up docker-compose -f ./docker/docker-compose.yml up
dev-update: dev-update:
docker-compose -f ./server/docker-compose.yml up --build -V docker-compose -f ./docker/docker-compose.yml up --build -V
dev-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --remove-orphans

View File

@@ -4,26 +4,52 @@
# IMMICH # IMMICH
Self-hosted Photo backup solution directly from your mobile phone. Self-hosted photo and video backup solution directly from your mobile phone.
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
Loading ~4000 images/videos
## Screenshots
<p align="left">
<img src="design/sc1.PNG" width="150" title="Login With Custom URL">
<img src="design/sc2.PNG" width="150" title="Backup Setting Info">
<img src="design/sc4.PNG" width="150" title="Home Page">
<img src="design/sc3.PNG" width="150" title="Multiple seelct">
<img src="design/sc5.PNG" width="150" title="Multipe select group">
</p>
# Note # Note
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
This project is under heavy development, there will be continous functions, features and api changes. This project is under heavy development, there will be continous functions, features and api changes.
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!** # Features
- Upload assets(videos/images).
- View assets.
- Quick navigation with drag scroll bar.
- Auto Backup.
- Support HEIC/HEIF Backup.
- Extract and display EXIF info.
- Real-time render from multi-device upload event.
- Image Classification based on ImageNet Dataset
# Development # Development
You can use docker compose for development, there are several services that compose Immich You can use docker compose for development, there are several services that compose Immich
1. The server 1. NestJs
2. PostgreSQL 2. PostgreSQL
3. Redis 3. Redis
4. Nginx 4. Nginx
5. TensorFlow and Keras
## Populate .env file ## Populate .env file
Navigate to `server` directory and run Navigate to `docker` directory and run
``` ```
cp .env.example .env cp .env.example .env
@@ -31,16 +57,18 @@ cp .env.example .env
Then populate the value in there. Then populate the value in there.
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
To start, run To start, run
```bash ```bash
docker-compose -f ./server/docker-compose.yml up docker-compose -f ./docker/docker-compose.yml up
``` ```
To force rebuild node modules after installing new packages To force rebuild node modules after installing new packages
```bash ```bash
docker-compose -f ./server/docker-compose.yml up --build -V docker-compose -f ./docker/docker-compose.yml up --build -V
``` ```
The server will be running at `http://your-ip:2283` through `Nginx` The server will be running at `http://your-ip:2283` through `Nginx`
@@ -79,7 +107,7 @@ flutter run --release
# Known Issue # Known Issue
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command ad make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture. 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.
```bash ```bash
more /proc/cpuinfo | grep flags more /proc/cpuinfo | grep flags

BIN
design/sc1.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

BIN
design/sc2.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
design/sc3.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

BIN
design/sc4.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

BIN
design/sc5.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

View File

@@ -7,7 +7,7 @@ DB_PASSWORD=postgres
DB_DATABASE_NAME= DB_DATABASE_NAME=
# Upload File Config # Upload File Config
UPLOAD_LOCATION=./upload UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
# JWT SECRET # JWT SECRET
JWT_SECRET= JWT_SECRET=

1
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

View File

@@ -0,0 +1,95 @@
version: "3.8"
services:
immich_server:
image: immich-server-dev:1.0.0
build:
context: ../server
target: development
dockerfile: ../server/Dockerfile
command: npm run start:dev
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
depends_on:
- redis
- database
networks:
- immich_network
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich_network
database:
container_name: immich_postgres
image: postgres:14
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich_network
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich_network
depends_on:
- immich_server
immich_tf_fastapi:
container_name: immich_tf_fastapi
image: tensor_flow_fastapi:1.0.0
restart: always
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
build:
context: ../machine_learning
target: gpu
dockerfile: ../machine_learning/Dockerfile
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
- ../machine_learning/app:/code/app
- ${UPLOAD_LOCATION}:/code/app/upload
ports:
- 2285:8000
expose:
- "8000"
depends_on:
- database
networks:
- immich_network
networks:
immich_network:
volumes:
pgdata:

View File

@@ -1,22 +1,19 @@
version: '3.8' version: "3.8"
services: services:
server: immich_server:
container_name: immich_server
image: immich-server-dev:1.0.0 image: immich-server-dev:1.0.0
build: build:
context: . context: ../server
target: development target: development
dockerfile: ./Dockerfile-minimal dockerfile: ../server/Dockerfile
command: yarn start:dev entrypoint: ["/bin/sh", "./entrypoint.sh"]
ports: # command: npm run start:dev
- "3000:3000" expose:
# expose: - "3000"
# - 3000
volumes: volumes:
- .:/usr/src/app - ../server:/usr/src/app
- userdata:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules - /usr/src/app/node_modules
env_file: env_file:
- .env - .env
@@ -30,7 +27,7 @@ services:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
networks: networks:
- immich_network - immich_network
database: database:
container_name: immich_postgres container_name: immich_postgres
@@ -47,7 +44,7 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
networks: networks:
- immich_network - immich_network
nginx: nginx:
container_name: proxy_nginx container_name: proxy_nginx
@@ -62,10 +59,30 @@ services:
networks: networks:
- immich_network - immich_network
depends_on: depends_on:
- 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:
pgdata: pgdata:
userdata:

View File

@@ -0,0 +1,35 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# events {
# worker_connections 1000;
# }
server {
client_max_body_size 50000M;
listen 80;
location / {
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich_server:3000;
}
}

View File

@@ -0,0 +1 @@
devenv/

3
machine_learning/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
devenv/
app/upload

View File

@@ -0,0 +1,22 @@
## GPU Build
# FROM tensorflow/tensorflow:latest-gpu as gpu
# WORKDIR /code
# COPY ./requirements.txt /code/requirements.txt
# RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
# COPY ./app /code/app
## CPU BUILD
FROM python:3.8 as cpu
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -0,0 +1,32 @@
from tensorflow.keras.applications import InceptionV3
from tensorflow.keras.applications.inception_v3 import preprocess_input, decode_predictions
from tensorflow.keras.preprocessing import image
import numpy as np
IMG_SIZE = 299
PREDICTION_MODEL = InceptionV3(weights='imagenet')
def classify_image(image_path: str):
img_path = f'./app/{image_path}'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
preds = PREDICTION_MODEL.predict(x)
result = decode_predictions(preds, top=3)[0]
payload = []
for _, value, _ in result:
payload.append(value)
return payload
def warm_up():
img_path = f'./app/test.png'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
PREDICTION_MODEL.predict(x)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
from pydantic import BaseModel
from fastapi import FastAPI
from .object_detection import object_detection
from .image_classifier import image_classifier
from tf2_yolov4.anchors import YOLOV4_ANCHORS
from tf2_yolov4.model import YOLOv4
HEIGHT, WIDTH = (640, 960)
# Warm up model
image_classifier.warm_up()
app = FastAPI()
class TagImagePayload(BaseModel):
thumbnail_path: str
@app.post("/tagImage")
async def post_root(payload: TagImagePayload):
image_path = payload.thumbnail_path
if image_path[0] == '.':
image_path = image_path[2:]
return image_classifier.classify_image(image_path=image_path)
@app.get("/")
async def test():
object_detection.run_detection()
# image = tf.io.read_file("./app/cars.jpg")
# image = tf.image.decode_image(image)
# image = tf.image.resize(image, (HEIGHT, WIDTH))
# images = tf.expand_dims(image, axis=0) / 255.0
# model = YOLOv4(
# (HEIGHT, WIDTH, 3),
# 80,
# YOLOV4_ANCHORS,
# "darknet",
# )

View File

@@ -0,0 +1,4 @@
def run_detection():
print("run detection")

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -0,0 +1,7 @@
fastapi>=0.68.0,<0.69.0
pydantic>=1.8.0,<2.0.0
uvicorn>=0.15.0,<0.16.0
tensorflow==2.8.0
numpy==1.22.2
pillow==9.0.1
tf2_yolov4==0.1.0

View File

@@ -1,16 +1 @@
# immich_mobile # Immich Mobile Application - Flutter
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
Few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook)
For help getting started with Flutter, view our
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -1,122 +1,158 @@
{ {
"images" : [ "images" : [
{ {
"size" : "20x20", "filename" : "immich-logo-1024-20@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png", "scale" : "2x",
"scale" : "2x" "size" : "20x20"
}, },
{ {
"size" : "20x20", "filename" : "immich-logo-1024-20@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png", "scale" : "3x",
"scale" : "3x" "size" : "20x20"
}, },
{ {
"size" : "29x29", "filename" : "immich-logo-1024-29.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png", "scale" : "1x",
"scale" : "1x" "size" : "29x29"
}, },
{ {
"size" : "29x29", "filename" : "immich-logo-1024-29@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png", "scale" : "2x",
"scale" : "2x" "size" : "29x29"
}, },
{ {
"size" : "29x29", "filename" : "immich-logo-1024-29@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png", "scale" : "3x",
"scale" : "3x" "size" : "29x29"
}, },
{ {
"size" : "40x40", "filename" : "immich-logo-1024-40@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png", "scale" : "2x",
"scale" : "2x" "size" : "40x40"
}, },
{ {
"size" : "40x40", "filename" : "immich-logo-1024-40@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png", "scale" : "3x",
"scale" : "3x" "size" : "40x40"
}, },
{ {
"size" : "60x60", "filename" : "immich-logo-1024-60@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png", "scale" : "2x",
"scale" : "2x" "size" : "60x60"
}, },
{ {
"size" : "60x60", "filename" : "immich-logo-1024-60@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png", "scale" : "3x",
"scale" : "3x" "size" : "60x60"
}, },
{ {
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png", "filename" : "Icon-App-20x20@1x.png",
"scale" : "1x" "idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
}, },
{ {
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png", "filename" : "Icon-App-20x20@2x.png",
"scale" : "2x" "idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
}, },
{ {
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png", "filename" : "Icon-App-29x29@1x.png",
"scale" : "1x" "idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
}, },
{ {
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png", "filename" : "Icon-App-29x29@2x.png",
"scale" : "2x" "idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
}, },
{ {
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png", "filename" : "Icon-App-40x40@1x.png",
"scale" : "1x" "idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
}, },
{ {
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png", "filename" : "Icon-App-40x40@2x.png",
"scale" : "2x" "idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
}, },
{ {
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png", "filename" : "Icon-App-76x76@1x.png",
"scale" : "1x" "idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
}, },
{ {
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png", "filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x",
"scale" : "2x" "size" : "76x76"
},
{
"filename" : "Icon-App-83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
}, },
{ {
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png", "filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x" "idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "immich-logo-1024-20.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "immich-logo-1024-40.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "immich-logo-1024-76.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "immich-logo-1024-76@2x.png",
"idiom" : "universal",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "immich-logo-1024-83.5@2x.png",
"idiom" : "universal",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "immich-logo-1024-1024.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "1024x1024"
} }
], ],
"info" : { "info" : {
"version" : 1, "author" : "xcode",
"author" : "xcode" "version" : 1
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -2,8 +2,11 @@ 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/routing/router.dart'; import 'package:immich_mobile/routing/router.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/websocket.provider.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@@ -35,25 +38,29 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
switch (state) { switch (state) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
debugPrint("[APP STATE] resumed"); debugPrint("[APP STATE] resumed");
ref.read(appStateProvider.notifier).state = AppStateEnum.resumed; ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed;
ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(websocketProvider.notifier).connect();
ref.watch(assetProvider.notifier).getAllAsset();
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive"); debugPrint("[APP STATE] inactive");
ref.read(appStateProvider.notifier).state = AppStateEnum.inactive; ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
ref.watch(websocketProvider.notifier).disconnect();
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
debugPrint("[APP STATE] paused"); debugPrint("[APP STATE] paused");
ref.read(appStateProvider.notifier).state = AppStateEnum.paused; ref.watch(appStateProvider.notifier).state = AppStateEnum.paused;
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
debugPrint("[APP STATE] detached"); debugPrint("[APP STATE] detached");
ref.read(appStateProvider.notifier).state = AppStateEnum.detached; ref.watch(appStateProvider.notifier).state = AppStateEnum.detached;
break; break;
} }
} }
Future<void> initApp() async { Future<void> initApp() async {
// WidgetsBinding.instance?.addObserver(this); WidgetsBinding.instance?.addObserver(this);
} }
@override @override

View File

@@ -0,0 +1,45 @@
import 'dart:convert';
class ImageViewerPageState {
final bool isBottomSheetEnable;
ImageViewerPageState({
required this.isBottomSheetEnable,
});
ImageViewerPageState copyWith({
bool? isBottomSheetEnable,
}) {
return ImageViewerPageState(
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
);
}
Map<String, dynamic> toMap() {
return {
'isBottomSheetEnable': isBottomSheetEnable,
};
}
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
return ImageViewerPageState(
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
@override
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
}
@override
int get hashCode => isBottomSheetEnable.hashCode;
}

View File

@@ -0,0 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
void toggleBottomSheet() {
bool isBottomSheetEnable = state.isBottomSheetEnable;
if (isBottomSheetEnable) {
state.copyWith(isBottomSheetEnable: false);
} else {
state.copyWith(isBottomSheetEnable: true);
}
}
}
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerPageStateNotifier()));

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
class ExifBottomSheet extends ConsumerWidget {
final ImmichAssetWithExif assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView(
children: [
assetDetail.exifInfo?.dateTimeOriginal != null
? Text(
DateFormat('E, LLL d, y • h:mm a').format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
),
style: TextStyle(
color: Colors.grey[400],
fontWeight: FontWeight.bold,
fontSize: 14,
),
)
: Container(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
"Add Description...",
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
),
// Location
assetDetail.exifInfo?.latitude != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Text(
"LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
)
],
),
)
: Container(),
// Detail
assetDetail.exifInfo != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"DETAILS",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "),
),
assetDetail.exifInfo?.make != null
? ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
)
: Container()
],
),
)
: Container()
],
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
final ImmichAsset asset;
final Function onMoreInfoPressed;
@override
Widget build(BuildContext context) {
double iconSize = 18.0;
return AppBar(
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
actions: [
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("backup");
},
icon: const Icon(Icons.backup_outlined),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("favorite");
},
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: const Icon(Icons.more_horiz_rounded))
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,108 @@
import 'package:cached_network_image/cached_network_image.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/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';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final ImmichAsset asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage(
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
useEffect(() {
getAssetExif();
return null;
}, []);
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
},
),
body: SafeArea(
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Wrap(
spacing: 32,
runSpacing: 32,
alignment: WrapAlignment.center,
children: [
const Text(
"Failed To Render Image - Possibly Corrupted Data",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.white),
),
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
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],
),
);
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'dart:convert';
class DeleteAssetResponse {
final String id;
final String status;
DeleteAssetResponse({
required this.id,
required this.status,
});
DeleteAssetResponse copyWith({
String? id,
String? status,
}) {
return DeleteAssetResponse(
id: id ?? this.id,
status: status ?? this.status,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'status': status,
};
}
factory DeleteAssetResponse.fromMap(Map<String, dynamic> map) {
return DeleteAssetResponse(
id: map['id'] ?? '',
status: map['status'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory DeleteAssetResponse.fromJson(String source) => DeleteAssetResponse.fromMap(json.decode(source));
@override
String toString() => 'DeleteAssetResponse(id: $id, status: $status)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DeleteAssetResponse && other.id == id && other.status == status;
}
@override
int get hashCode => id.hashCode ^ status.hashCode;
}

View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class HomePageState {
final bool isMultiSelectEnable;
final Set<ImmichAsset> selectedItems;
final Set<String> selectedDateGroup;
HomePageState({
required this.isMultiSelectEnable,
required this.selectedItems,
required this.selectedDateGroup,
});
HomePageState copyWith({
bool? isMultiSelectEnable,
Set<ImmichAsset>? selectedItems,
Set<String>? selectedDateGroup,
}) {
return HomePageState(
isMultiSelectEnable: isMultiSelectEnable ?? this.isMultiSelectEnable,
selectedItems: selectedItems ?? this.selectedItems,
selectedDateGroup: selectedDateGroup ?? this.selectedDateGroup,
);
}
Map<String, dynamic> toMap() {
return {
'isMultiSelectEnable': isMultiSelectEnable,
'selectedItems': selectedItems.map((x) => x.toMap()).toList(),
'selectedDateGroup': selectedDateGroup.toList(),
};
}
factory HomePageState.fromMap(Map<String, dynamic> map) {
return HomePageState(
isMultiSelectEnable: map['isMultiSelectEnable'] ?? false,
selectedItems: Set<ImmichAsset>.from(map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))),
selectedDateGroup: Set<String>.from(map['selectedDateGroup']),
);
}
String toJson() => json.encode(toMap());
factory HomePageState.fromJson(String source) => HomePageState.fromMap(json.decode(source));
@override
String toString() =>
'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals;
return other is HomePageState &&
other.isMultiSelectEnable == isMultiSelectEnable &&
setEquals(other.selectedItems, selectedItems) &&
setEquals(other.selectedDateGroup, selectedDateGroup);
}
@override
int get hashCode => isMultiSelectEnable.hashCode ^ selectedItems.hashCode ^ selectedDateGroup.hashCode;
}

View File

@@ -1,60 +1,74 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
import 'package:immich_mobile/modules/home/services/asset.service.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/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> { class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
final imagePerPage = 100;
final AssetService _assetService = AssetService(); final AssetService _assetService = AssetService();
final DeviceInfoService _deviceInfoService = DeviceInfoService();
final Ref ref;
AssetNotifier() : super([]); AssetNotifier(this.ref) : super([]);
late String? nextPageKey = "";
bool isFetching = false;
getImmichAssets() async { getAllAsset() async {
GetAllAssetResponse? res = await _assetService.getAllAsset(); List<ImmichAsset>? allAssets = await _assetService.getAllAsset();
nextPageKey = res?.nextPageKey;
if (res != null) { if (allAssets != null) {
for (var assets in res.data) { state = allAssets;
state = [...state, assets];
}
}
}
getMoreAsset() async {
if (nextPageKey != null && !isFetching) {
isFetching = true;
GetAllAssetResponse? res = await _assetService.getMoreAsset(nextPageKey);
if (res != null) {
nextPageKey = res.nextPageKey;
List<ImmichAssetGroupByDate> previousState = state;
List<ImmichAssetGroupByDate> currentState = [];
for (var assets in res.data) {
currentState = [...currentState, assets];
}
if (previousState.last.date == currentState.first.date) {
previousState.last.assets = [...previousState.last.assets, ...currentState.first.assets];
state = [...previousState, ...currentState.sublist(1)];
} else {
state = [...previousState, ...currentState];
}
}
isFetching = false;
} }
} }
clearAllAsset() { clearAllAsset() {
state = []; state = [];
} }
onNewAssetUploaded(ImmichAsset newAsset) {
state = [...state, newAsset];
}
deleteAssets(Set<ImmichAsset> deleteAssets) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
List<String> deleteIdList = [];
// Delete asset from device
for (var asset in deleteAssets) {
// Delete asset on device if present
if (asset.deviceId == deviceId) {
AssetEntity? localAsset = await AssetEntity.fromId(asset.deviceAssetId);
if (localAsset != null) {
deleteIdList.add(localAsset.id);
}
}
}
final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
// Delete asset on server
List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets);
if (deleteAssetResult == null) {
return;
}
for (var asset in deleteAssetResult) {
if (asset.status == 'success') {
state = state.where((immichAsset) => immichAsset.id != asset.id).toList();
}
}
}
} }
final currentLocalPageProvider = StateProvider<int>((ref) => 0); final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
return AssetNotifier(ref);
final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAssetGroupByDate>>((ref) { });
return AssetNotifier();
final assetGroupByDateTimeProvider = 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('y-MM-dd').format(DateTime.parse(element.createdAt)));
}); });

View File

@@ -0,0 +1,63 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class HomePageStateNotifier extends StateNotifier<HomePageState> {
HomePageStateNotifier()
: super(
HomePageState(
isMultiSelectEnable: false,
selectedItems: {},
selectedDateGroup: {},
),
);
void addSelectedDateGroup(String dateGroupTitle) {
state = state.copyWith(selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle});
}
void removeSelectedDateGroup(String dateGroupTitle) {
var currentDateGroup = state.selectedDateGroup;
currentDateGroup.removeWhere((e) => e == dateGroupTitle);
state = state.copyWith(selectedDateGroup: currentDateGroup);
}
void enableMultiSelect(Set<ImmichAsset> selectedItems) {
state = state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
}
void disableMultiSelect() {
state = state.copyWith(isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {});
}
void addSingleSelectedItem(ImmichAsset asset) {
state = state.copyWith(selectedItems: {...state.selectedItems, asset});
}
void addMultipleSelectedItems(List<ImmichAsset> assets) {
state = state.copyWith(selectedItems: {...state.selectedItems, ...assets});
}
void removeSingleSelectedItem(ImmichAsset asset) {
Set<ImmichAsset> currentList = state.selectedItems;
currentList.removeWhere((e) => e.id == asset.id);
state = state.copyWith(selectedItems: currentList);
}
void removeMultipleSelectedItem(List<ImmichAsset> assets) {
Set<ImmichAsset> currentList = state.selectedItems;
for (ImmichAsset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedItems: currentList);
}
}
final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(((ref) => HomePageStateNotifier()));

View File

@@ -1,13 +1,29 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_response.model.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:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
class AssetService { class AssetService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
Future<GetAllAssetResponse?> getAllAsset() async { Future<List<ImmichAsset>?> getAllAsset() async {
var res = await _networkService.getRequest(url: "asset/");
try {
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
}
return null;
}
Future<GetAllAssetResponse?> getAllAssetWithPagination() async {
var res = await _networkService.getRequest(url: "asset/all"); var res = await _networkService.getRequest(url: "asset/all");
try { try {
Map<String, dynamic> decodedData = jsonDecode(res.toString()); Map<String, dynamic> decodedData = jsonDecode(res.toString());
@@ -17,9 +33,10 @@ class AssetService {
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
} }
return null;
} }
Future<GetAllAssetResponse?> getMoreAsset(String? nextPageKey) async { Future<GetAllAssetResponse?> getOlderAsset(String? nextPageKey) async {
try { try {
var res = await _networkService.getRequest( var res = await _networkService.getRequest(
url: "asset/all?nextPageKey=$nextPageKey", url: "asset/all?nextPageKey=$nextPageKey",
@@ -34,5 +51,63 @@ class AssetService {
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
} }
return null;
}
Future<List<ImmichAsset>> getNewAsset(String latestDate) async {
try {
var res = await _networkService.getRequest(
url: "asset/new?latestDate=$latestDate",
);
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
if (result.isNotEmpty) {
return result;
}
return [];
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return [];
}
}
Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
try {
var res = await _networkService.getRequest(
url: "asset/assetById/$assetId",
);
Map<String, dynamic> decodedData = jsonDecode(res.toString());
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null;
}
}
Future<List<DeleteAssetResponse>?> deleteAssets(Set<ImmichAsset> deleteAssets) async {
try {
var payload = [];
for (var asset in deleteAssets) {
payload.add(asset.id);
}
var res = await _networkService.deleteRequest(url: "asset/", data: {"ids": payload});
List<dynamic> decodedData = jsonDecode(res.toString());
List<DeleteAssetResponse> result = List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null;
}
} }
} }

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
class ControlBottomAppBar extends StatelessWidget {
const ControlBottomAppBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
color: Colors.grey[300]?.withOpacity(0.98),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ControlBoxButton(
iconData: Icons.delete_forever_rounded,
label: "Delete",
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return const DeleteDialog();
},
);
},
),
],
),
)
],
),
),
);
}
}
class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed})
: super(key: key);
final String label;
final IconData iconData;
final Function onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
onPressed: () {
onPressed();
},
icon: Icon(iconData, size: 30),
),
Text(label)
],
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<ImmichAsset> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && selectedDateGroup.contains(dateText) && selectedDateGroup.length > 1) {
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup);
} else {
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet());
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DeleteDialog extends ConsumerWidget {
const DeleteDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final homePageState = ref.watch(homePageStateProvider);
return AlertDialog(
backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("Delete Permanently"),
content: const Text("These items will be permanently deleted from Immich and from your device"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text(
"Cancel",
style: TextStyle(color: Colors.blueGrey),
),
),
TextButton(
onPressed: () {
ref.watch(assetProvider.notifier).deleteAssets(homePageState.selectedItems);
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
Navigator.of(context).pop();
},
child: Text(
"Delete",
style: TextStyle(color: Colors.red[400]),
),
),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
Key? key,
required this.onPressed,
required this.selectedItemCount,
}) : super(key: key);
final Function onPressed;
final int selectedItemCount;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
top: 0,
left: 0,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 46),
child: Material(
elevation: 20,
borderRadius: BorderRadius.circular(35),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(35),
color: Colors.grey[100],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
selectedItemCount.toString(),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
)),
),
),
),
),
);
}
}

View File

@@ -1,8 +1,9 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.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: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/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
@@ -11,95 +12,93 @@ import 'package:immich_mobile/shared/providers/backup.provider.dart';
class ImmichSliverAppBar extends ConsumerWidget { class ImmichSliverAppBar extends ConsumerWidget {
const ImmichSliverAppBar({ const ImmichSliverAppBar({
Key? key, Key? key,
required this.imageGridGroup, this.onPopBack,
}) : super(key: key); }) : super(key: key);
final List<Widget> imageGridGroup; final Function? onPopBack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider); final BackUpState _backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
return SliverPadding( return SliverAppBar(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), centerTitle: true,
sliver: SliverAppBar( floating: true,
centerTitle: true, pinned: false,
floating: true, snap: false,
pinned: false, backgroundColor: Colors.grey[200],
snap: false, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
backgroundColor: Colors.grey[200], leading: Builder(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), builder: (BuildContext context) {
leading: Builder( return IconButton(
builder: (BuildContext context) { icon: const Icon(Icons.account_circle_rounded),
return IconButton( onPressed: () {
icon: const Icon(Icons.account_circle_rounded), Scaffold.of(context).openDrawer();
onPressed: () { },
Scaffold.of(context).openDrawer(); tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
}, );
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, },
);
},
),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Theme.of(context).primaryColor,
),
),
),
actions: [
Stack(
alignment: AlignmentDirectional.center,
children: [
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
top: 10,
right: 12,
child: SizedBox(
height: 8,
width: 8,
child: CircularProgressIndicator(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
),
),
)
: Container(),
IconButton(
icon: const Icon(Icons.backup_rounded),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
// Remove and force getting new widget again if there is not many widget on screen.
// Otherwise do nothing.
if (imageGridGroup.isNotEmpty && imageGridGroup.length < 20) {
print("Get more access");
ref.read(assetProvider.notifier).getMoreAsset();
} else if (imageGridGroup.isEmpty) {
print("get immich asset");
ref.read(assetProvider.notifier).getImmichAssets();
}
}
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)
: Container()
],
),
],
), ),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
),
actions: [
Stack(
alignment: AlignmentDirectional.center,
children: [
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
top: 10,
right: 12,
child: SizedBox(
height: 8,
width: 8,
child: CircularProgressIndicator(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
),
),
)
: Container(),
IconButton(
splashRadius: 25,
iconSize: 30,
icon: _isEnableAutoBackup
? const Icon(Icons.backup_rounded)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 1,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
Icons.cloud_off_rounded,
size: 8,
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () {
AutoRouter.of(context).push(const BackupControllerRoute());
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)
: Container()
],
),
],
); );
} }
} }

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM y').format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}

View File

@@ -1,12 +1,11 @@
import 'package:auto_route/annotations.dart';
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/src/widgets/framework.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/modules/home/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/routing/router.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends ConsumerWidget { class ProfileDrawer extends ConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key); const ProfileDrawer({Key? key}) : super(key: key);
@@ -58,9 +57,11 @@ class ProfileDrawer extends ConsumerWidget {
), ),
onTap: () async { onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout(); bool res = await ref.read(authenticationProvider.notifier).logout();
ref.read(assetProvider.notifier).clearAllAsset();
if (res) { if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).popUntilRoot(); AutoRouter.of(context).popUntilRoot();
} }
}, },

View File

@@ -1,66 +1,135 @@
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:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class ThumbnailImage extends HookWidget { class ThumbnailImage extends HookConsumerWidget {
final ImmichAsset asset; final ImmichAsset asset;
const ThumbnailImage({Key? key, required this.asset}) : super(key: key); const ThumbnailImage({Key? key, required this.asset}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1); final cacheKey = useState(1);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(ImmichAsset asset) {
if (selectedAsset.contains(asset)) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (asset.type == 'IMAGE') { debugPrint("View ${asset.id}");
AutoRouter.of(context).push( if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
ImageViewerRoute( ref.watch(homePageStateProvider.notifier).disableMultiSelect();
imageUrl: } else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', ref.watch(homePageStateProvider.notifier).removeSingleSelectedItem(asset);
heroTag: asset.id, } else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
thumbnailUrl: thumbnailRequestUrl, ref.watch(homePageStateProvider.notifier).addSingleSelectedItem(asset);
),
);
} else { } else {
debugPrint("Navigate to video player"); 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 {
debugPrint("Navigate to video player");
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}',
), ),
); );
}
} }
}, },
onLongPress: () {}, onLongPress: () {
// Enable multi selecte function
ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset});
HapticFeedback.heavyImpact();
},
child: Hero( child: Hero(
tag: asset.id, tag: asset.id,
child: CachedNetworkImage( child: Stack(
cacheKey: "${asset.id}-${cacheKey.value}", children: [
width: 300, Container(
height: 300, decoration: BoxDecoration(
memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, border: isMultiSelectEnable && selectedAsset.contains(asset)
fit: BoxFit.cover, ? Border.all(color: Theme.of(context).primaryColorLight, width: 10)
imageUrl: thumbnailRequestUrl, : const Border(),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, ),
fadeInDuration: const Duration(milliseconds: 250), child: CachedNetworkImage(
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( cacheKey: "${asset.id}-${cacheKey.value}",
scale: 0.2, width: 300,
child: CircularProgressIndicator(value: downloadProgress.progress), height: 300,
), memCacheHeight: asset.type == 'IMAGE' ? 250 : 400,
errorWidget: (context, url, error) { fit: BoxFit.cover,
debugPrint("Error Loading Thumbnail Widget $error"); imageUrl: thumbnailRequestUrl,
cacheKey.value += 1; httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
return const Icon(Icons.error); 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,
);
},
),
),
Container(
child: isMultiSelectEnable
? Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset),
),
)
: Container(),
),
Positioned(
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),
)
],
), ),
), ),
); );

View File

@@ -1,166 +1,112 @@
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/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/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/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:intl/intl.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key); const HomePage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ValueNotifier<bool> _showBackToTopBtn = useState(false);
ScrollController _scrollController = useScrollController(); ScrollController _scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
List<ImmichAssetGroupByDate> assetGroup = ref.watch(assetProvider); List<Widget> _imageGridGroup = [];
List<Widget> imageGridGroup = []; var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var homePageState = ref.watch(homePageStateProvider);
_scrollControllerCallback() {
var endOfPage = _scrollController.position.maxScrollExtent;
if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) {
ref.read(assetProvider.notifier).getMoreAsset();
}
if (_scrollController.offset >= 400) {
_showBackToTopBtn.value = true;
} else {
_showBackToTopBtn.value = false;
}
}
useEffect(() { useEffect(() {
ref.read(assetProvider.notifier).getImmichAssets(); ref.read(websocketProvider.notifier).connect();
ref.read(assetProvider.notifier).getAllAsset();
_scrollController.addListener(_scrollControllerCallback); return null;
return () {
_scrollController.removeListener(_scrollControllerCallback);
};
}, []); }, []);
Widget _buildBody() { Widget _buildBody() {
if (assetGroup.isNotEmpty) { if (assetGroupByDateTime.isNotEmpty) {
String lastGroupDate = assetGroup[0].date; int? lastMonth;
for (var group in assetGroup) { assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
var dateTitle = group.date; DateTime parseDateGroup = DateTime.parse(dateGroup);
var assetGroup = group.assets; int currentMonth = parseDateGroup.month;
int? currentMonth = DateTime.tryParse(dateTitle)?.month; if (lastMonth != null) {
int? previousMonth = DateTime.tryParse(lastGroupDate)?.month; if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add(
// Add Monthly Title Group if started at the beginning of the month MonthlyTitleText(
if ((currentMonth! - previousMonth!) != 0) { isoDate: dateGroup,
imageGridGroup.add( ),
MonthlyTitleText(isoDate: dateTitle), );
); }
} }
// Add Daily Title Group _imageGridGroup.add(
imageGridGroup.add( DailyTitleText(
DailyTitleText(isoDate: dateTitle), isoDate: dateGroup,
assetGroup: immichAssetList,
),
); );
// Add Image Group _imageGridGroup.add(
imageGridGroup.add( ImageGrid(assetGroup: immichAssetList),
ImageGrid(assetGroup: assetGroup),
); );
//
lastGroupDate = dateTitle; lastMonth = currentMonth;
} });
} }
return SafeArea( return SafeArea(
child: DraggableScrollbar.semicircle( bottom: !isMultiSelectEnable,
backgroundColor: Theme.of(context).primaryColor, top: !isMultiSelectEnable,
controller: _scrollController, child: Stack(
heightScrollThumb: 48.0, children: [
child: CustomScrollView( DraggableScrollbar.semicircle(
controller: _scrollController, backgroundColor: Theme.of(context).primaryColor,
slivers: [ controller: _scrollController,
ImmichSliverAppBar(imageGridGroup: imageGridGroup), heightScrollThumb: 48.0,
...imageGridGroup, child: CustomScrollView(
], controller: _scrollController,
), slivers: [
SliverAnimatedSwitcher(
child: isMultiSelectEnable
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: const ImmichSliverAppBar(),
duration: const Duration(milliseconds: 350),
),
..._imageGridGroup
],
),
),
isMultiSelectEnable
? DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length,
)
: Container(),
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
],
), ),
); );
} }
return Scaffold( return Scaffold(
// key: _scaffoldKey,
drawer: const ProfileDrawer(), drawer: const ProfileDrawer(),
body: _buildBody(), body: _buildBody(),
); );
} }
} }
class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 10.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}
class DailyTitleText extends StatelessWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0),
child: Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
],
),
),
);
}
}

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/shared/models/device_info.model.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> { class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier() AuthenticationNotifier(this.ref)
: super( : super(
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
@@ -31,6 +31,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
), ),
); );
final Ref ref;
final DeviceInfoService _deviceInfoService = DeviceInfoService(); final DeviceInfoService _deviceInfoService = DeviceInfoService();
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@@ -126,5 +127,5 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
} }
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(); return AuthenticationNotifier(ref);
}); });

View File

@@ -3,7 +3,9 @@ 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: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/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/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {
@@ -13,36 +15,38 @@ class LoginForm extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com'); final usernameController = useTextEditingController(text: 'testuser@email.com');
final passwordController = useTextEditingController(text: 'password'); final passwordController = useTextEditingController(text: 'password');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283');
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
child: Wrap( child: SingleChildScrollView(
spacing: 32, child: Wrap(
runSpacing: 32, spacing: 32,
alignment: WrapAlignment.center, runSpacing: 32,
children: [ alignment: WrapAlignment.center,
const Image( children: [
image: AssetImage('assets/immich-logo-no-outline.png'), const Image(
width: 128, image: AssetImage('assets/immich-logo-no-outline.png'),
filterQuality: FilterQuality.high, width: 128,
), filterQuality: FilterQuality.high,
Text( ),
'IMMICH', Text(
style: GoogleFonts.snowburstOne( 'IMMICH',
textStyle: style: GoogleFonts.snowburstOne(
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)), textStyle:
), TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
EmailInput(controller: usernameController), ),
PasswordInput(controller: passwordController), EmailInput(controller: usernameController),
ServerEndpointInput(controller: serverEndpointController), PasswordInput(controller: passwordController),
LoginButton( ServerEndpointInput(controller: serverEndpointController),
emailController: usernameController, LoginButton(
passwordController: passwordController, emailController: usernameController,
serverEndpointController: serverEndpointController, passwordController: passwordController,
), serverEndpointController: serverEndpointController,
], ),
],
),
), ),
), ),
); );
@@ -110,16 +114,22 @@ class LoginButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton( return ElevatedButton(
onPressed: () async { onPressed: () async {
// This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenicated = await ref var isAuthenicated = 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 (isAuthenicated) {
AutoRouter.of(context).pushNamed("/home-page"); // Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
// AutoRouter.of(context).pushNamed("/home-page");
AutoRouter.of(context).pushNamed("/tab-controller-page");
} else { } else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Error logging you in, check server url, emald and password!", msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error); toastType: ToastType.error);
} }
}, },

View File

@@ -1,7 +1,5 @@
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/login/ui/login_form.dart'; import 'package:immich_mobile/modules/login/ui/login_form.dart';
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {

View File

@@ -0,0 +1,131 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
class SearchPageState {
final String searchTerm;
final bool isSearchEnabled;
final List<String> searchSuggestion;
final List<String> userSuggestedSearchTerms;
SearchPageState({
required this.searchTerm,
required this.isSearchEnabled,
required this.searchSuggestion,
required this.userSuggestedSearchTerms,
});
SearchPageState copyWith({
String? searchTerm,
bool? isSearchEnabled,
List<String>? searchSuggestion,
List<String>? userSuggestedSearchTerms,
}) {
return SearchPageState(
searchTerm: searchTerm ?? this.searchTerm,
isSearchEnabled: isSearchEnabled ?? this.isSearchEnabled,
searchSuggestion: searchSuggestion ?? this.searchSuggestion,
userSuggestedSearchTerms: userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
);
}
Map<String, dynamic> toMap() {
return {
'searchTerm': searchTerm,
'isSearchEnabled': isSearchEnabled,
'searchSuggestion': searchSuggestion,
'userSuggestedSearchTerms': userSuggestedSearchTerms,
};
}
factory SearchPageState.fromMap(Map<String, dynamic> map) {
return SearchPageState(
searchTerm: map['searchTerm'] ?? '',
isSearchEnabled: map['isSearchEnabled'] ?? false,
searchSuggestion: List<String>.from(map['searchSuggestion']),
userSuggestedSearchTerms: List<String>.from(map['userSuggestedSearchTerms']),
);
}
String toJson() => json.encode(toMap());
factory SearchPageState.fromJson(String source) => SearchPageState.fromMap(json.decode(source));
@override
String toString() {
return 'SearchPageState(searchTerm: $searchTerm, isSearchEnabled: $isSearchEnabled, searchSuggestion: $searchSuggestion, userSuggestedSearchTerms: $userSuggestedSearchTerms)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is SearchPageState &&
other.searchTerm == searchTerm &&
other.isSearchEnabled == isSearchEnabled &&
listEquals(other.searchSuggestion, searchSuggestion) &&
listEquals(other.userSuggestedSearchTerms, userSuggestedSearchTerms);
}
@override
int get hashCode {
return searchTerm.hashCode ^
isSearchEnabled.hashCode ^
searchSuggestion.hashCode ^
userSuggestedSearchTerms.hashCode;
}
}
class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
SearchPageStateNotifier()
: super(
SearchPageState(
searchTerm: "",
isSearchEnabled: false,
searchSuggestion: [],
userSuggestedSearchTerms: [],
),
);
final SearchService _searchService = SearchService();
void enableSearch() {
state = state.copyWith(isSearchEnabled: true);
}
void disableSearch() {
state = state.copyWith(isSearchEnabled: false);
}
void setSearchTerm(String value) {
state = state.copyWith(searchTerm: value);
_getSearchSuggestion(state.searchTerm);
}
void _getSearchSuggestion(String searchTerm) {
var searchList = state.userSuggestedSearchTerms;
var newList = searchList.where((e) => e.toLowerCase().contains(searchTerm));
state = state.copyWith(searchSuggestion: [...newList]);
if (searchTerm.isEmpty) {
state = state.copyWith(searchSuggestion: []);
}
}
void getSuggestedSearchTerms() async {
var userSuggestedSearchTerms = await _searchService.getUserSuggestedSearchTerms();
state = state.copyWith(userSuggestedSearchTerms: userSuggestedSearchTerms);
}
}
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier();
});

View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class SearchresultPageState {
final bool isLoading;
final bool isSuccess;
final bool isError;
final List<ImmichAsset> searchResult;
SearchresultPageState({
required this.isLoading,
required this.isSuccess,
required this.isError,
required this.searchResult,
});
SearchresultPageState copyWith({
bool? isLoading,
bool? isSuccess,
bool? isError,
List<ImmichAsset>? searchResult,
}) {
return SearchresultPageState(
isLoading: isLoading ?? this.isLoading,
isSuccess: isSuccess ?? this.isSuccess,
isError: isError ?? this.isError,
searchResult: searchResult ?? this.searchResult,
);
}
Map<String, dynamic> toMap() {
return {
'isLoading': isLoading,
'isSuccess': isSuccess,
'isError': isError,
'searchResult': searchResult.map((x) => x.toMap()).toList(),
};
}
factory SearchresultPageState.fromMap(Map<String, dynamic> map) {
return SearchresultPageState(
isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false,
searchResult: List<ImmichAsset>.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))),
);
}
String toJson() => json.encode(toMap());
factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source));
@override
String toString() {
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is SearchresultPageState &&
other.isLoading == isLoading &&
other.isSuccess == isSuccess &&
other.isError == isError &&
listEquals(other.searchResult, searchResult);
}
@override
int get hashCode {
return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode;
}
}
class SearchResultPageStateNotifier extends StateNotifier<SearchresultPageState> {
SearchResultPageStateNotifier()
: super(SearchresultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
final SearchService _searchService = SearchService();
search(String searchTerm) async {
state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
if (assets != null) {
state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
} else {
state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
}
}
}
final searchResultPageStateProvider =
StateNotifierProvider<SearchResultPageStateNotifier, SearchresultPageState>((ref) {
return SearchResultPageStateNotifier();
});
final searchResultGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(searchResultPageStateProvider).searchResult;
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)));
});

View File

@@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
class SearchService {
final NetworkService _networkService = NetworkService();
Future<List<String>?> getUserSuggestedSearchTerms() async {
try {
var res = await _networkService.getRequest(url: "asset/searchTerm");
List<dynamic> decodedData = jsonDecode(res.toString());
return List.from(decodedData);
} catch (e) {
debugPrint("[ERROR] [getUserSuggestedSearchTerms] ${e.toString()}");
return [];
}
}
Future<List<ImmichAsset>?> searchAsset(String searchTerm) async {
try {
var res = await _networkService.postRequest(
url: "asset/search",
data: {"searchTerm": searchTerm},
);
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null;
}
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key);
final FocusNode searchFocusNode;
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTermController = useTextEditingController(text: "");
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
return AppBar(
automaticallyImplyLeading: false,
leading: isSearchEnabled
? IconButton(
onPressed: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
searchTermController.clear();
},
icon: const Icon(Icons.arrow_back_ios_rounded))
: const Icon(Icons.search_rounded),
title: TextField(
controller: searchTermController,
focusNode: searchFocusNode,
autofocus: false,
onTap: () {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
ref.watch(searchPageStateProvider.notifier).enableSearch();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode.requestFocus();
},
onSubmitted: (searchTerm) {
onSubmitted(searchTerm);
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
},
decoration: const InputDecoration(
hintText: 'Search your photos',
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchSuggestionList extends ConsumerWidget {
const SearchSuggestionList({Key? key, required this.onSubmitted}) : super(key: key);
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
final searchSuggestion = ref.watch(searchPageStateProvider).searchSuggestion;
return Container(
color: searchTerm.isEmpty ? Colors.black.withOpacity(0.5) : Theme.of(context).scaffoldBackgroundColor,
child: CustomScrollView(
slivers: [
SliverFillRemaining(
hasScrollBody: true,
child: ListView.builder(
itemBuilder: ((context, index) {
return ListTile(
onTap: () {
onSubmitted(searchSuggestion[index]);
},
title: Text(searchSuggestion[index]),
);
}),
itemCount: searchSuggestion.length,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,78 @@
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/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart';
// ignore: must_be_immutable
class SearchPage extends HookConsumerWidget {
SearchPage({Key? key}) : super(key: key);
late FocusNode searchFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
useEffect(() {
searchFocusNode = FocusNode();
return () => searchFocusNode.dispose();
}, []);
_onSearchSubmitted(String searchTerm) async {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
}
return Scaffold(
appBar: SearchBar(
searchFocusNode: searchFocusNode,
onSubmitted: _onSearchSubmitted,
),
body: GestureDetector(
onTap: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
},
child: Stack(
children: [
ListView(
children: [
Container(
height: 300,
color: Colors.blue,
),
Container(
height: 300,
color: Colors.red,
),
Container(
height: 300,
color: Colors.green,
),
Container(
height: 300,
color: Colors.blue,
),
Container(
height: 300,
color: Colors.red,
),
Container(
height: 300,
color: Colors.green,
),
],
),
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
],
),
),
);
}
}

View File

@@ -0,0 +1,197 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_result_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
class SearchResultPage extends HookConsumerWidget {
SearchResultPage({Key? key, required this.searchTerm}) : super(key: key);
final String searchTerm;
late FocusNode searchFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
ScrollController _scrollController = useScrollController();
final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm);
List<Widget> _imageGridGroup = [];
useEffect(() {
searchFocusNode = FocusNode();
Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm));
return () => searchFocusNode.dispose();
}, []);
_onSearchSubmitted(String newSearchTerm) {
debugPrint("Re-Search with $newSearchTerm");
searchFocusNode.unfocus();
isNewSearch.value = false;
currentSearchTerm.value = newSearchTerm;
ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm);
}
_buildTextField() {
return TextField(
controller: searchTermController,
focusNode: searchFocusNode,
autofocus: false,
onTap: () {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode.requestFocus();
},
textInputAction: TextInputAction.search,
onSubmitted: (searchTerm) {
if (searchTerm.isNotEmpty) {
searchTermController.clear();
_onSearchSubmitted(searchTerm);
} else {
isNewSearch.value = false;
}
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
},
decoration: const InputDecoration(
hintText: 'New Search',
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
);
}
_buildChip() {
return Chip(
label: Wrap(
spacing: 5,
children: [
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
currentSearchTerm.value,
style: TextStyle(color: Theme.of(context).primaryColor),
maxLines: 1,
),
),
Icon(
Icons.close_rounded,
color: Theme.of(context).primaryColor,
size: 20,
),
],
),
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
);
}
_buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageStateProvider);
var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
if (searchResultPageState.isError) {
return const Text("Error");
}
if (searchResultPageState.isLoading) {
return const CircularProgressIndicator.adaptive();
}
if (searchResultPageState.isSuccess) {
if (searchResultPageState.searchResult.isNotEmpty) {
int? lastMonth;
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
DateTime parseDateGroup = DateTime.parse(dateGroup);
int currentMonth = parseDateGroup.month;
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
);
}
}
_imageGridGroup.add(
DailyTitleText(
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
_imageGridGroup.add(
ImageGrid(assetGroup: immichAssetList),
);
lastMonth = currentMonth;
});
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
slivers: [..._imageGridGroup],
),
);
} else {
return const Text("No assets found");
}
}
return Container();
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
splashRadius: 20,
onPressed: () {
if (isNewSearch.value) {
isNewSearch.value = false;
} else {
AutoRouter.of(context).pop(true);
}
},
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: GestureDetector(
onTap: () {
isNewSearch.value = true;
searchFocusNode.requestFocus();
},
child: isNewSearch.value ? _buildTextField() : _buildChip()),
centerTitle: false,
),
body: GestureDetector(
onTap: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
},
child: Stack(
children: [
_buildSearchResult(),
isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
],
),
),
);
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
class AuthGuard extends AutoRouteGuard { class AuthGuard extends AutoRouteGuard {

View File

@@ -2,9 +2,13 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.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_result_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/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart';
import 'package:immich_mobile/shared/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/video_viewer_page.dart'; import 'package:immich_mobile/shared/views/video_viewer_page.dart';
part 'router.gr.dart'; part 'router.gr.dart';
@@ -13,10 +17,18 @@ part 'router.gr.dart';
replaceInRouteName: 'Page,Route', replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[ routes: <AutoRoute>[
AutoRoute(page: LoginPage, initial: true), AutoRoute(page: LoginPage, initial: true),
AutoRoute(page: HomePage, guards: [AuthGuard]), AutoRoute(
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), page: TabControllerPage,
guards: [AuthGuard],
children: [
AutoRoute(page: HomePage, guards: [AuthGuard]),
AutoRoute(page: SearchPage, 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: SearchResultPage, guards: [AuthGuard]),
], ],
) )
class AppRouter extends _$AppRouter { class AppRouter extends _$AppRouter {

View File

@@ -25,13 +25,9 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: const LoginPage()); routeData: routeData, child: const LoginPage());
}, },
HomeRoute.name: (routeData) { TabControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage()); routeData: routeData, child: const TabControllerPage());
},
BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const BackupControllerPage());
}, },
ImageViewerRoute.name: (routeData) { ImageViewerRoute.name: (routeData) {
final args = routeData.argsAs<ImageViewerRouteArgs>(); final args = routeData.argsAs<ImageViewerRouteArgs>();
@@ -41,26 +37,63 @@ class _$AppRouter extends RootStackRouter {
key: args.key, key: args.key,
imageUrl: args.imageUrl, imageUrl: args.imageUrl,
heroTag: args.heroTag, heroTag: args.heroTag,
thumbnailUrl: args.thumbnailUrl)); thumbnailUrl: args.thumbnailUrl,
asset: args.asset));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
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));
},
BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const BackupControllerPage());
},
SearchResultRoute.name: (routeData) {
final args = routeData.argsAs<SearchResultRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
},
SearchRoute.name: (routeData) {
final args = routeData.argsAs<SearchRouteArgs>(
orElse: () => const SearchRouteArgs());
return MaterialPageX<dynamic>(
routeData: routeData, child: SearchPage(key: args.key));
} }
}; };
@override @override
List<RouteConfig> get routes => [ List<RouteConfig> get routes => [
RouteConfig(LoginRoute.name, path: '/'), RouteConfig(LoginRoute.name, path: '/'),
RouteConfig(HomeRoute.name, path: '/home-page', guards: [authGuard]), RouteConfig(TabControllerRoute.name,
RouteConfig(BackupControllerRoute.name, path: '/tab-controller-page',
path: '/backup-controller-page', guards: [authGuard]), guards: [
authGuard
],
children: [
RouteConfig(HomeRoute.name,
path: 'home-page',
parent: TabControllerRoute.name,
guards: [authGuard]),
RouteConfig(SearchRoute.name,
path: 'search-page',
parent: TabControllerRoute.name,
guards: [authGuard])
]),
RouteConfig(ImageViewerRoute.name, RouteConfig(ImageViewerRoute.name,
path: '/image-viewer-page', guards: [authGuard]), path: '/image-viewer-page', guards: [authGuard]),
RouteConfig(VideoViewerRoute.name, RouteConfig(VideoViewerRoute.name,
path: '/video-viewer-page', guards: [authGuard]) path: '/video-viewer-page', guards: [authGuard]),
RouteConfig(BackupControllerRoute.name,
path: '/backup-controller-page', guards: [authGuard]),
RouteConfig(SearchResultRoute.name,
path: '/search-result-page', guards: [authGuard])
]; ];
} }
@@ -73,20 +106,13 @@ class LoginRoute extends PageRouteInfo<void> {
} }
/// generated route for /// generated route for
/// [HomePage] /// [TabControllerPage]
class HomeRoute extends PageRouteInfo<void> { class TabControllerRoute extends PageRouteInfo<void> {
const HomeRoute() : super(HomeRoute.name, path: '/home-page'); const TabControllerRoute({List<PageRouteInfo>? children})
: super(TabControllerRoute.name,
path: '/tab-controller-page', initialChildren: children);
static const String name = 'HomeRoute'; static const String name = 'TabControllerRoute';
}
/// generated route for
/// [BackupControllerPage]
class BackupControllerRoute extends PageRouteInfo<void> {
const BackupControllerRoute()
: super(BackupControllerRoute.name, path: '/backup-controller-page');
static const String name = 'BackupControllerRoute';
} }
/// generated route for /// generated route for
@@ -96,14 +122,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
{Key? key, {Key? key,
required String imageUrl, required String imageUrl,
required String heroTag, required String heroTag,
required String thumbnailUrl}) required String thumbnailUrl,
required ImmichAsset asset})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
args: ImageViewerRouteArgs( args: ImageViewerRouteArgs(
key: key, key: key,
imageUrl: imageUrl, imageUrl: imageUrl,
heroTag: heroTag, heroTag: heroTag,
thumbnailUrl: thumbnailUrl)); thumbnailUrl: thumbnailUrl,
asset: asset));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
} }
@@ -113,7 +141,8 @@ class ImageViewerRouteArgs {
{this.key, {this.key,
required this.imageUrl, required this.imageUrl,
required this.heroTag, required this.heroTag,
required this.thumbnailUrl}); required this.thumbnailUrl,
required this.asset});
final Key? key; final Key? key;
@@ -123,9 +152,11 @@ class ImageViewerRouteArgs {
final String thumbnailUrl; final String thumbnailUrl;
final ImmichAsset asset;
@override @override
String toString() { String toString() {
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}'; return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
} }
} }
@@ -152,3 +183,65 @@ class VideoViewerRouteArgs {
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}'; return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}';
} }
} }
/// generated route for
/// [BackupControllerPage]
class BackupControllerRoute extends PageRouteInfo<void> {
const BackupControllerRoute()
: super(BackupControllerRoute.name, path: '/backup-controller-page');
static const String name = 'BackupControllerRoute';
}
/// generated route for
/// [SearchResultPage]
class SearchResultRoute extends PageRouteInfo<SearchResultRouteArgs> {
SearchResultRoute({Key? key, required String searchTerm})
: super(SearchResultRoute.name,
path: '/search-result-page',
args: SearchResultRouteArgs(key: key, searchTerm: searchTerm));
static const String name = 'SearchResultRoute';
}
class SearchResultRouteArgs {
const SearchResultRouteArgs({this.key, required this.searchTerm});
final Key? key;
final String searchTerm;
@override
String toString() {
return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {
const HomeRoute() : super(HomeRoute.name, path: 'home-page');
static const String name = 'HomeRoute';
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
SearchRoute({Key? key})
: super(SearchRoute.name,
path: 'search-page', args: SearchRouteArgs(key: key));
static const String name = 'SearchRoute';
}
class SearchRouteArgs {
const SearchRouteArgs({this.key});
final Key? key;
@override
String toString() {
return 'SearchRouteArgs{key: $key}';
}
}

View File

@@ -0,0 +1,187 @@
import 'dart:convert';
class ImmichExif {
final int? id;
final String? assetId;
final String? make;
final String? model;
final String? imageName;
final int? exifImageWidth;
final int? exifImageHeight;
final int? fileSizeInByte;
final String? orientation;
final String? dateTimeOriginal;
final String? modifyDate;
final String? lensModel;
final double? fNumber;
final double? focalLength;
final int? iso;
final double? exposureTime;
final double? latitude;
final double? longitude;
ImmichExif({
this.id,
this.assetId,
this.make,
this.model,
this.imageName,
this.exifImageWidth,
this.exifImageHeight,
this.fileSizeInByte,
this.orientation,
this.dateTimeOriginal,
this.modifyDate,
this.lensModel,
this.fNumber,
this.focalLength,
this.iso,
this.exposureTime,
this.latitude,
this.longitude,
});
ImmichExif copyWith({
int? id,
String? assetId,
String? make,
String? model,
String? imageName,
int? exifImageWidth,
int? exifImageHeight,
int? fileSizeInByte,
String? orientation,
String? dateTimeOriginal,
String? modifyDate,
String? lensModel,
double? fNumber,
double? focalLength,
int? iso,
double? exposureTime,
double? latitude,
double? longitude,
}) {
return ImmichExif(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
make: make ?? this.make,
model: model ?? this.model,
imageName: imageName ?? this.imageName,
exifImageWidth: exifImageWidth ?? this.exifImageWidth,
exifImageHeight: exifImageHeight ?? this.exifImageHeight,
fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
orientation: orientation ?? this.orientation,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
modifyDate: modifyDate ?? this.modifyDate,
lensModel: lensModel ?? this.lensModel,
fNumber: fNumber ?? this.fNumber,
focalLength: focalLength ?? this.focalLength,
iso: iso ?? this.iso,
exposureTime: exposureTime ?? this.exposureTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'assetId': assetId,
'make': make,
'model': model,
'imageName': imageName,
'exifImageWidth': exifImageWidth,
'exifImageHeight': exifImageHeight,
'fileSizeInByte': fileSizeInByte,
'orientation': orientation,
'dateTimeOriginal': dateTimeOriginal,
'modifyDate': modifyDate,
'lensModel': lensModel,
'fNumber': fNumber,
'focalLength': focalLength,
'iso': iso,
'exposureTime': exposureTime,
'latitude': latitude,
'longitude': longitude,
};
}
factory ImmichExif.fromMap(Map<String, dynamic> map) {
return ImmichExif(
id: map['id']?.toInt(),
assetId: map['assetId'],
make: map['make'],
model: map['model'],
imageName: map['imageName'],
exifImageWidth: map['exifImageWidth']?.toInt(),
exifImageHeight: map['exifImageHeight']?.toInt(),
fileSizeInByte: map['fileSizeInByte']?.toInt(),
orientation: map['orientation'],
dateTimeOriginal: map['dateTimeOriginal'],
modifyDate: map['modifyDate'],
lensModel: map['lensModel'],
fNumber: map['fNumber']?.toDouble(),
focalLength: map['focalLength']?.toDouble(),
iso: map['iso']?.toInt(),
exposureTime: map['exposureTime']?.toDouble(),
latitude: map['latitude']?.toDouble(),
longitude: map['longitude']?.toDouble(),
);
}
String toJson() => json.encode(toMap());
factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichExif &&
other.id == id &&
other.assetId == assetId &&
other.make == make &&
other.model == model &&
other.imageName == imageName &&
other.exifImageWidth == exifImageWidth &&
other.exifImageHeight == exifImageHeight &&
other.fileSizeInByte == fileSizeInByte &&
other.orientation == orientation &&
other.dateTimeOriginal == dateTimeOriginal &&
other.modifyDate == modifyDate &&
other.lensModel == lensModel &&
other.fNumber == fNumber &&
other.focalLength == focalLength &&
other.iso == iso &&
other.exposureTime == exposureTime &&
other.latitude == latitude &&
other.longitude == longitude;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
make.hashCode ^
model.hashCode ^
imageName.hashCode ^
exifImageWidth.hashCode ^
exifImageHeight.hashCode ^
fileSizeInByte.hashCode ^
orientation.hashCode ^
dateTimeOriginal.hashCode ^
modifyDate.hashCode ^
lensModel.hashCode ^
fNumber.hashCode ^
focalLength.hashCode ^
iso.hashCode ^
exposureTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
}
}

View File

@@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:immich_mobile/shared/models/exif.model.dart';
class ImmichAssetWithExif {
final String id;
final String deviceAssetId;
final String userId;
final String deviceId;
final String type;
final String createdAt;
final String modifiedAt;
final String originalPath;
final bool isFavorite;
final String? duration;
final ImmichExif? exifInfo;
ImmichAssetWithExif({
required this.id,
required this.deviceAssetId,
required this.userId,
required this.deviceId,
required this.type,
required this.createdAt,
required this.modifiedAt,
required this.originalPath,
required this.isFavorite,
this.duration,
this.exifInfo,
});
ImmichAssetWithExif copyWith({
String? id,
String? deviceAssetId,
String? userId,
String? deviceId,
String? type,
String? createdAt,
String? modifiedAt,
String? originalPath,
bool? isFavorite,
String? duration,
ImmichExif? exifInfo,
}) {
return ImmichAssetWithExif(
id: id ?? this.id,
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
userId: userId ?? this.userId,
deviceId: deviceId ?? this.deviceId,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
modifiedAt: modifiedAt ?? this.modifiedAt,
originalPath: originalPath ?? this.originalPath,
isFavorite: isFavorite ?? this.isFavorite,
duration: duration ?? this.duration,
exifInfo: exifInfo ?? this.exifInfo,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'deviceAssetId': deviceAssetId,
'userId': userId,
'deviceId': deviceId,
'type': type,
'createdAt': createdAt,
'modifiedAt': modifiedAt,
'originalPath': originalPath,
'isFavorite': isFavorite,
'duration': duration,
'exifInfo': exifInfo?.toMap(),
};
}
factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
return ImmichAssetWithExif(
id: map['id'] ?? '',
deviceAssetId: map['deviceAssetId'] ?? '',
userId: map['userId'] ?? '',
deviceId: map['deviceId'] ?? '',
type: map['type'] ?? '',
createdAt: map['createdAt'] ?? '',
modifiedAt: map['modifiedAt'] ?? '',
originalPath: map['originalPath'] ?? '',
isFavorite: map['isFavorite'] ?? false,
duration: map['duration'],
exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
);
}
String toJson() => json.encode(toMap());
factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, originalPath: $originalPath, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetWithExif &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.userId == userId &&
other.deviceId == deviceId &&
other.type == type &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.originalPath == originalPath &&
other.isFavorite == isFavorite &&
other.duration == duration &&
other.exifInfo == exifInfo;
}
@override
int get hashCode {
return id.hashCode ^
deviceAssetId.hashCode ^
userId.hashCode ^
deviceId.hashCode ^
type.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
originalPath.hashCode ^
isFavorite.hashCode ^
duration.hashCode ^
exifInfo.hashCode;
}
}

View File

@@ -1,6 +1,11 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart';
@@ -8,7 +13,7 @@ import 'package:immich_mobile/shared/services/backup.service.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier() BackupNotifier({this.ref})
: super( : super(
BackUpState( BackUpState(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
@@ -29,21 +34,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
), ),
); );
Ref? ref;
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService(); final ServerInfoService _serverInfoService = ServerInfoService();
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();
if (list.isEmpty) { if (list.isEmpty) {
debugPrint("No Asset On Device"); debugPrint("No Asset On Device");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length);
return; return;
} }
int totalAsset = list[0].assetCount; int totalAsset = list[0].assetCount;
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length); state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
} }
@@ -61,19 +70,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
List<AssetPathEntity> list = List<AssetPathEntity> list =
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common); await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
// Get device assets info from database
// Compare and find different assets that has not been backing up
// Backup those assets
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
if (list.isEmpty) { if (list.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process"); debugPrint("No Asset On Device - Abort Backup Process");
state = state.copyWith(
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);
// Get device assets info from database
// Compare and find different assets that has not been backing up
// Backup those assets
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
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) {
@@ -96,10 +107,10 @@ 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); state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
} }
void _onAssetUploaded() { void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state =
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1); state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
@@ -130,8 +141,38 @@ class BackupNotifier extends StateNotifier<BackUpState> {
), ),
); );
} }
void resumeBackup() {
var authState = ref?.read(authenticationProvider);
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
// User has been logged out return
if (authState != null) {
if (accessKey == null || !authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
return;
}
// Check if this device is enable backup by the user
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
}
return;
}
}
} }
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) { final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(); return BackupNotifier(ref: ref);
}); });

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:socket_io_client/socket_io_client.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
class WebscoketState {
final Socket? socket;
final bool isConnected;
WebscoketState({
this.socket,
required this.isConnected,
});
WebscoketState copyWith({
Socket? socket,
bool? isConnected,
}) {
return WebscoketState(
socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected,
);
}
@override
String toString() => 'WebscoketState(socket: $socket, isConnected: $isConnected)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is WebscoketState && other.socket == socket && other.isConnected == isConnected;
}
@override
int get hashCode => socket.hashCode ^ isConnected.hashCode;
}
class WebsocketNotifier extends StateNotifier<WebscoketState> {
WebsocketNotifier(this.ref) : super(WebscoketState(socket: null, isConnected: false)) {
debugPrint("Init websocket instance");
}
final Ref ref;
connect() {
var authenticationState = ref.read(authenticationProvider);
if (authenticationState.isAuthenticated) {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
try {
debugPrint("[WEBSOCKET] Attempting to connect to ws");
// Configure socket transports must be sepecified
Socket socket = io(
endpoint,
OptionBuilder()
.setTransports(['websocket'])
.enableReconnection()
.enableForceNew()
.enableForceNewConnection()
.enableAutoConnect()
.setExtraHeaders({"Authorization": "Bearer $accessToken"})
.build(),
);
socket.onConnect((_) {
debugPrint("[WEBSOCKET] Established Websocket Connection");
state = WebscoketState(isConnected: true, socket: socket);
});
socket.onDisconnect((_) {
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
state = WebscoketState(isConnected: false, socket: null);
});
socket.on('error', (errorMessage) {
debugPrint("Webcoket Error - $errorMessage");
state = WebscoketState(isConnected: false, socket: null);
});
socket.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
ImmichAsset newAsset = ImmichAsset.fromMap(jsonString);
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
});
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
}
}
disconnect() {
debugPrint("[WEBSOCKET] Attempting to disconnect");
var socket = state.socket?.disconnect();
if (socket != null) {
if (socket.disconnected) {
state = WebscoketState(isConnected: false, socket: null);
}
}
}
}
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
return WebsocketNotifier(ref);
});

View File

@@ -26,7 +26,7 @@ class BackupService {
return result.cast<String>(); return result.cast<String>();
} }
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function singleAssetDoneCb, backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async { Function(int, int) uploadProgress) async {
var dio = Dio(); var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor()); dio.interceptors.add(AuthenticatedRequestInterceptor());
@@ -37,20 +37,12 @@ class BackupService {
for (var entity in assetList) { for (var entity in assetList) {
try { try {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
file = await entity.file; file = await entity.originFile;
} else { } else {
file = await entity.file.timeout(const Duration(seconds: 5)); file = await entity.originFile.timeout(const Duration(seconds: 5));
} }
if (file != null) { if (file != null) {
// reading exif
// var exifData = await readExifFromFile(file);
// for (String key in exifData.keys) {
// debugPrint("- $key (${exifData[key]?.tagType}): ${exifData[key]}");
// }
// debugPrint("------------------");
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);
@@ -85,7 +77,7 @@ class BackupService {
); );
if (res.statusCode == 201) { if (res.statusCode == 201) {
singleAssetDoneCb(); singleAssetDoneCb(entity.id, deviceId);
} }
} }
} on DioError catch (e) { } on DioError catch (e) {

View File

@@ -7,6 +7,24 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart'; import 'package:immich_mobile/utils/dio_http_interceptor.dart';
class NetworkService { class NetworkService {
Future<dynamic> deleteRequest({required String url, dynamic data}) async {
try {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Response res = await dio.delete('$savedEndpoint/$url', data: data);
if (res.statusCode == 200) {
return res;
}
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
} catch (e) {
debugPrint("ERROR getRequest: ${e.toString()}");
}
}
Future<dynamic> getRequest({required String url}) async { Future<dynamic> getRequest({required String url}) async {
try { try {
var dio = Dio(); var dio = Dio();

View File

@@ -22,6 +22,8 @@ class BackupControllerPage extends HookConsumerWidget {
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) { if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo(); ref.read(backupProvider.notifier).getBackupInfo();
} }
return null;
}, []); }, []);
Widget _buildStorageInformation() { Widget _buildStorageInformation() {

View File

@@ -1,64 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
class ImageViewerPage extends StatelessWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
: super(key: key);
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(Icons.arrow_back_ios)),
),
body: Dismissible(
direction: DismissDirection.vertical,
onDismissed: (_) {
AutoRouter.of(context).pop();
},
key: Key(heroTag),
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => const Icon(Icons.error),
placeholder: (context, url) {
return CachedNetworkImage(
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) => const Icon(Icons.error),
);
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class TabControllerPage extends ConsumerWidget {
const TabControllerPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
return AutoTabsRouter(
routes: [
const HomeRoute(),
SearchRoute(),
],
builder: (context, child, animation) {
final tabsRouter = AutoTabsRouter.of(context);
return Scaffold(
body: FadeTransition(
opacity: animation,
child: child,
),
bottomNavigationBar: isMultiSelectEnable
? null
: BottomNavigationBar(
selectedLabelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
currentIndex: tabsRouter.activeIndex,
onTap: (index) {
tabsRouter.setActiveIndex(index);
},
items: const [
BottomNavigationBarItem(label: 'Photos', icon: Icon(Icons.photo)),
BottomNavigationBarItem(label: 'Seach', icon: Icon(Icons.search)),
],
),
);
},
);
}
}

View File

@@ -1,5 +1,6 @@
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/services.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
@@ -17,6 +18,7 @@ class VideoViewerPage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
systemOverlayStyle: SystemUiOverlayStyle.light,
backgroundColor: Colors.black, backgroundColor: Colors.black,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
@@ -24,7 +26,7 @@ class VideoViewerPage extends StatelessWidget {
}, },
icon: const Icon(Icons.arrow_back_ios)), icon: const Icon(Icons.arrow_back_ios)),
), ),
body: Center( body: SafeArea(
child: VideoThumbnailPlayer( child: VideoThumbnailPlayer(
url: videoUrl, url: videoUrl,
jwtToken: jwtToken, jwtToken: jwtToken,
@@ -64,7 +66,6 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
setState(() {}); setState(() {});
} catch (e) { } catch (e) {
debugPrint("ERROR initialize video player"); debugPrint("ERROR initialize video player");
print(e);
} }
} }

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class FileHelper { class FileHelper {
static getMimeType(String filePath) { static getMimeType(String filePath) {
debugPrint(filePath);
var fileExtension = p.extension(filePath).split(".")[1]; var fileExtension = p.extension(filePath).split(".")[1];
switch (fileExtension.toLowerCase()) { switch (fileExtension.toLowerCase()) {
@@ -28,6 +26,12 @@ class FileHelper {
case 'avi': case 'avi':
return {"type": "video", "subType": "x-msvideo"}; return {"type": "video", "subType": "x-msvideo"};
case 'heic':
return {"type": "image", "subType": "heic"};
case 'heif':
return {"type": "image", "subType": "heif"};
default: default:
return {"type": "unsupport", "subType": "unsupport"}; return {"type": "unsupport", "subType": "unsupport"};
} }

View File

@@ -2,7 +2,7 @@ build:
flutter packages pub run build_runner build flutter packages pub run build_runner build
watch: watch:
flutter packages pub run build_runner watch flutter packages pub run build_runner watch --delete-conflicting-outputs
create_app_icon: create_app_icon:
flutter pub run flutter_launcher_icons:main flutter pub run flutter_launcher_icons:main

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