Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5990a28870 | ||
|
|
bd34be92e6 | ||
|
|
e608c61ba5 | ||
|
|
d2edc0bffe | ||
|
|
bfde308492 | ||
|
|
f181dba964 | ||
|
|
c894e36855 | ||
|
|
01e906e99c | ||
|
|
352800223e | ||
|
|
619735fea0 | ||
|
|
75b1ed08b4 | ||
|
|
c234c95880 | ||
|
|
7cc7fc0a0c | ||
|
|
897d49f734 | ||
|
|
051c958c8b | ||
|
|
56627caf5b | ||
|
|
4f47c8c06b | ||
|
|
de1dbcea9c | ||
|
|
d1498506a8 | ||
|
|
9bcbdd31ce | ||
|
|
38c968d47e | ||
|
|
f578ca6d47 |
2
.github/workflows/Build+push Immich.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
|||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
#platforms: linux/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
|
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
2
LICENSE
@@ -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
|
||||||
|
|||||||
7
Makefile
@@ -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
|
||||||
|
|||||||
30
README.md
@@ -22,32 +22,34 @@ Loading ~4000 images/videos
|
|||||||
|
|
||||||
# Note
|
# Note
|
||||||
|
|
||||||
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 !!**
|
**!! 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.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
[x] Upload assets(videos/images)
|
- Upload assets(videos/images).
|
||||||
|
- View assets.
|
||||||
[x] View assets
|
- Quick navigation with drag scroll bar.
|
||||||
|
- Auto Backup.
|
||||||
[x] Quick navigation with drag scroll bar
|
- Support HEIC/HEIF Backup.
|
||||||
|
- Extract and display EXIF info.
|
||||||
[x] Auto Backup
|
- 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
|
||||||
@@ -55,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`
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
95
docker/docker-compose.gpu.yml
Normal 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:
|
||||||
@@ -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
|
dockerfile: ../server/Dockerfile
|
||||||
command: npm run 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
|
||||||
@@ -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:
|
|
||||||
35
docker/settings/nginx-conf/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
machine_learning/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
devenv/
|
||||||
3
machine_learning/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
devenv/
|
||||||
|
app/upload
|
||||||
22
machine_learning/Dockerfile
Normal 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
|
||||||
0
machine_learning/app/__init__.py
Normal file
BIN
machine_learning/app/cars.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
0
machine_learning/app/image_classifier/__init__.py
Normal file
32
machine_learning/app/image_classifier/image_classifier.py
Normal 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)
|
||||||
1002
machine_learning/app/imagenet_class_index.json
Normal file
46
machine_learning/app/main.py
Normal 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",
|
||||||
|
# )
|
||||||
0
machine_learning/app/object_detection/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
def run_detection():
|
||||||
|
print("run detection")
|
||||||
BIN
machine_learning/app/test.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
7
machine_learning/requirements.txt
Normal 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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -2,9 +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/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';
|
||||||
|
|
||||||
@@ -36,20 +38,23 @@ 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.read(backupProvider.notifier).resumeBackup();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
118
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
Normal 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()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
Normal 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);
|
||||||
|
}
|
||||||
108
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
Normal 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],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,99 +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/models/immich_asset.model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||||
import 'package:collection/collection.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 AssetService _assetService = AssetService();
|
final AssetService _assetService = AssetService();
|
||||||
|
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
AssetNotifier() : super([]);
|
AssetNotifier(this.ref) : super([]);
|
||||||
|
|
||||||
late String? nextPageKey = "";
|
getAllAsset() async {
|
||||||
bool isFetching = false;
|
List<ImmichAsset>? allAssets = await _assetService.getAllAsset();
|
||||||
|
|
||||||
// Get All assets
|
if (allAssets != null) {
|
||||||
getImmichAssets() async {
|
state = allAssets;
|
||||||
GetAllAssetResponse? res = await _assetService.getAllAsset();
|
|
||||||
nextPageKey = res?.nextPageKey;
|
|
||||||
|
|
||||||
if (res != null) {
|
|
||||||
for (var assets in res.data) {
|
|
||||||
state = [...state, assets];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Asset From The Past
|
|
||||||
getOlderAsset() async {
|
|
||||||
if (nextPageKey != null && !isFetching) {
|
|
||||||
isFetching = true;
|
|
||||||
GetAllAssetResponse? res = await _assetService.getOlderAsset(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get newer asset from the current time
|
|
||||||
getNewAsset() async {
|
|
||||||
if (state.isNotEmpty) {
|
|
||||||
var latestGroup = state.first;
|
|
||||||
|
|
||||||
// Sort the last asset group and put the lastest asset in front.
|
|
||||||
latestGroup.assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
|
||||||
var latestAsset = latestGroup.assets.first;
|
|
||||||
var formatDateTemplate = 'y-MM-dd';
|
|
||||||
var latestAssetDateText = DateFormat(formatDateTemplate).format(DateTime.parse(latestAsset.createdAt));
|
|
||||||
|
|
||||||
List<ImmichAsset> newAssets = await _assetService.getNewAsset(latestAsset.createdAt);
|
|
||||||
|
|
||||||
if (newAssets.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouping by data
|
|
||||||
var groupByDateList = groupBy<ImmichAsset, String>(
|
|
||||||
newAssets, (asset) => DateFormat(formatDateTemplate).format(DateTime.parse(asset.createdAt)));
|
|
||||||
|
|
||||||
groupByDateList.forEach((groupDateInFormattedText, assets) {
|
|
||||||
if (groupDateInFormattedText != latestAssetDateText) {
|
|
||||||
ImmichAssetGroupByDate newGroup = ImmichAssetGroupByDate(assets: assets, date: groupDateInFormattedText);
|
|
||||||
state = [newGroup, ...state];
|
|
||||||
} else {
|
|
||||||
latestGroup.assets.insertAll(0, assets);
|
|
||||||
|
|
||||||
state = [latestGroup, ...state.sublist(1)];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +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.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());
|
||||||
@@ -58,4 +73,41 @@ class AssetService {
|
|||||||
return [];
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
mobile/lib/modules/home/ui/control_bottom_app_bar.dart
Normal 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)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,31 @@ class DailyTitleText extends ConsumerWidget {
|
|||||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||||
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
|
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(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
|
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
|
||||||
@@ -39,33 +64,16 @@ class DailyTitleText extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: _handleTitleIconClick,
|
||||||
if (isMultiSelectEnable &&
|
|
||||||
selectedDateGroup.contains(dateText) &&
|
|
||||||
selectedDateGroup.length == 1 &&
|
|
||||||
selectedItems.length == assetGroup.length) {
|
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
|
||||||
} else if (isMultiSelectEnable &&
|
|
||||||
selectedDateGroup.contains(dateText) &&
|
|
||||||
selectedItems.length != assetGroup.length) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
||||||
? const Icon(Icons.check_circle_rounded)
|
? Icon(
|
||||||
: const Icon(Icons.check_circle_outline_rounded),
|
Icons.check_circle_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
43
mobile/lib/modules/home/ui/delete_diaglog.dart
Normal 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]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
mobile/lib/modules/home/ui/disable_multi_select_button.dart
Normal 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),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +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/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';
|
||||||
@@ -10,89 +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,
|
this.onPopBack,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final List<Widget> imageGridGroup;
|
|
||||||
final Function? onPopBack;
|
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) {
|
|
||||||
onPopBack!();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_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()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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/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);
|
||||||
@@ -57,8 +59,10 @@ class ProfileDrawer extends ConsumerWidget {
|
|||||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||||
|
|
||||||
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();
|
||||||
ref.read(assetProvider.notifier).clearAllAsset();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:hive_flutter/hive_flutter.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.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';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
||||||
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
|
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||||
|
|
||||||
Widget _buildSelectionIcon(ImmichAsset asset) {
|
Widget _buildSelectionIcon(ImmichAsset asset) {
|
||||||
if (selectedAsset.contains(asset)) {
|
if (selectedAsset.contains(asset)) {
|
||||||
@@ -42,6 +44,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
debugPrint("View ${asset.id}");
|
||||||
if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
|
if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||||
} else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
|
} else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
|
||||||
@@ -56,6 +59,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
|
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
|
||||||
heroTag: asset.id,
|
heroTag: asset.id,
|
||||||
thumbnailUrl: thumbnailRequestUrl,
|
thumbnailUrl: thumbnailRequestUrl,
|
||||||
|
asset: asset,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -98,9 +102,10 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
debugPrint("Error Loading Thumbnail Widget $error");
|
return Icon(
|
||||||
cacheKey.value += 1;
|
Icons.image_not_supported_outlined,
|
||||||
return const Icon(Icons.error);
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -115,6 +120,15 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
: Container(),
|
: Container(),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 10,
|
||||||
|
bottom: 5,
|
||||||
|
child: Icon(
|
||||||
|
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
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/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/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: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);
|
||||||
@@ -16,94 +20,91 @@ class HomePage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
ScrollController _scrollController = useScrollController();
|
ScrollController _scrollController = useScrollController();
|
||||||
List<ImmichAssetGroupByDate> _assetGroup = ref.watch(assetProvider);
|
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||||
List<Widget> _imageGridGroup = [];
|
List<Widget> _imageGridGroup = [];
|
||||||
|
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
_scrollControllerCallback() {
|
var homePageState = ref.watch(homePageStateProvider);
|
||||||
var endOfPage = _scrollController.position.maxScrollExtent;
|
|
||||||
|
|
||||||
if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) {
|
|
||||||
ref.read(assetProvider.notifier).getOlderAsset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
onPopBackFromBackupPage() {
|
|
||||||
ref.read(assetProvider.notifier).getNewAsset();
|
|
||||||
// Remove and force getting new widget again if there is not many widget on screen.
|
|
||||||
// Otherwise do nothing.
|
|
||||||
|
|
||||||
if (_imageGridGroup.isNotEmpty && _imageGridGroup.length < 20) {
|
|
||||||
ref.read(assetProvider.notifier).getOlderAsset();
|
|
||||||
} else if (_imageGridGroup.isEmpty) {
|
|
||||||
ref.read(assetProvider.notifier).getImmichAssets();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|
||||||
// Add Monthly Title Group if started at the beginning of the month
|
|
||||||
|
|
||||||
if (currentMonth != null && previousMonth != null) {
|
|
||||||
if ((currentMonth - previousMonth) != 0) {
|
|
||||||
_imageGridGroup.add(
|
_imageGridGroup.add(
|
||||||
MonthlyTitleText(isoDate: dateTitle),
|
MonthlyTitleText(
|
||||||
|
isoDate: dateGroup,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Daily Title Group
|
|
||||||
_imageGridGroup.add(
|
_imageGridGroup.add(
|
||||||
DailyTitleText(isoDate: dateTitle, assetGroup: assetGroup),
|
DailyTitleText(
|
||||||
|
isoDate: dateGroup,
|
||||||
|
assetGroup: immichAssetList,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add Image Group
|
|
||||||
_imageGridGroup.add(
|
_imageGridGroup.add(
|
||||||
ImageGrid(assetGroup: assetGroup),
|
ImageGrid(assetGroup: immichAssetList),
|
||||||
);
|
);
|
||||||
//
|
|
||||||
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(
|
heightScrollThumb: 48.0,
|
||||||
imageGridGroup: _imageGridGroup,
|
child: CustomScrollView(
|
||||||
onPopBack: onPopBackFromBackupPage,
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
SliverAnimatedSwitcher(
|
||||||
|
child: isMultiSelectEnable
|
||||||
|
? const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 70,
|
||||||
|
child: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const ImmichSliverAppBar(),
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
),
|
||||||
|
..._imageGridGroup
|
||||||
|
],
|
||||||
),
|
),
|
||||||
..._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(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,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,
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -122,11 +124,12 @@ class LoginButton extends ConsumerWidget {
|
|||||||
if (isAuthenicated) {
|
if (isAuthenicated) {
|
||||||
// Resume backup (if enable) then navigate
|
// Resume backup (if enable) then navigate
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
ref.watch(backupProvider.notifier).resumeBackup();
|
||||||
AutoRouter.of(context).pushNamed("/home-page");
|
// AutoRouter.of(context).pushNamed("/home-page");
|
||||||
|
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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)));
|
||||||
|
});
|
||||||
39
mobile/lib/modules/search/services/search.service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
mobile/lib/modules/search/ui/search_bar.dart
Normal 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);
|
||||||
|
}
|
||||||
36
mobile/lib/modules/search/ui/search_suggestion_list.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
mobile/lib/modules/search/views/search_page.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
mobile/lib/modules/search/views/search_result_page.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
187
mobile/lib/shared/models/exif.model.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
mobile/lib/shared/models/immich_asset_with_exif.model.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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:hive_flutter/hive_flutter.dart';
|
||||||
@@ -11,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(this.ref)
|
BackupNotifier({this.ref})
|
||||||
: super(
|
: super(
|
||||||
BackUpState(
|
BackUpState(
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
@@ -32,22 +34,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final Ref ref;
|
Ref? ref;
|
||||||
final BackupService _backupService = BackupService();
|
final BackupService _backupService = BackupService();
|
||||||
final ServerInfoService _serverInfoService = ServerInfoService();
|
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||||
|
final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast();
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -65,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) {
|
||||||
@@ -103,7 +110,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
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);
|
||||||
|
|
||||||
@@ -136,36 +143,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void resumeBackup() {
|
void resumeBackup() {
|
||||||
debugPrint("[resumeBackup]");
|
var authState = ref?.read(authenticationProvider);
|
||||||
var authState = ref.read(authenticationProvider);
|
|
||||||
|
|
||||||
// Check if user is login
|
// Check if user is login
|
||||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|
||||||
// User has been logged out return
|
// User has been logged out return
|
||||||
if (accessKey == null || !authState.isAuthenticated) {
|
if (authState != null) {
|
||||||
debugPrint("[resumeBackup] not authenticated - abort");
|
if (accessKey == null || !authState.isAuthenticated) {
|
||||||
return;
|
debugPrint("[resumeBackup] not authenticated - abort");
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run backup
|
// Check if this device is enable backup by the user
|
||||||
debugPrint("[resumeBackup] Start back up");
|
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||||
startBackupProcess();
|
// check if backup is alreayd in process - then return
|
||||||
}
|
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||||
|
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint("[resumeBackup] User disables auto backup");
|
// Run backup
|
||||||
return;
|
debugPrint("[resumeBackup] Start back up");
|
||||||
|
startBackupProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||||
return BackupNotifier(ref);
|
return BackupNotifier(ref: ref);
|
||||||
});
|
});
|
||||||
|
|||||||
113
mobile/lib/shared/providers/websocket.provider.dart
Normal 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);
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
mobile/lib/shared/views/tab_controller_page.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -50,6 +50,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.1"
|
||||||
|
badges:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: badges
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -639,6 +646,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.10"
|
version: "1.3.10"
|
||||||
|
photo_view:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: photo_view
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -721,6 +735,27 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.99"
|
version: "0.0.99"
|
||||||
|
sliver_tools:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sliver_tools
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.5"
|
||||||
|
socket_io_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: socket_io_client
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0-beta.4-nullsafety.0"
|
||||||
|
socket_io_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: socket_io_common
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
source_gen:
|
source_gen:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ dependencies:
|
|||||||
fluttertoast: ^8.0.8
|
fluttertoast: ^8.0.8
|
||||||
video_player: ^2.2.18
|
video_player: ^2.2.18
|
||||||
chewie: ^1.2.2
|
chewie: ^1.2.2
|
||||||
|
sliver_tools: ^0.2.5
|
||||||
|
badges: ^2.0.2
|
||||||
|
photo_view: ^0.13.0
|
||||||
|
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
##################################
|
##################################
|
||||||
# DEVELOPMENT
|
# DEVELOPMENT
|
||||||
##################################
|
##################################
|
||||||
FROM node:16-bullseye-slim AS development
|
FROM node:16-alpine3.14 AS development
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -9,8 +9,7 @@ WORKDIR /usr/src/app
|
|||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
RUN apt-get update
|
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
@@ -18,44 +17,25 @@ COPY . .
|
|||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Clean up commands
|
#################################
|
||||||
RUN apt-get autoremove -y && apt-get clean && \
|
|
||||||
rm -rf /usr/local/src/*
|
|
||||||
|
|
||||||
RUN apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
##################################
|
|
||||||
# PRODUCTION
|
# PRODUCTION
|
||||||
##################################
|
#################################
|
||||||
# FROM node:16-bullseye-slim as production
|
FROM node:16-alpine3.14 AS production
|
||||||
# ARG DEBIAN_FRONTEND=noninteractive
|
|
||||||
# ARG NODE_ENV=production
|
|
||||||
# ENV NODE_ENV=${NODE_ENV}
|
|
||||||
|
|
||||||
# WORKDIR /usr/src/app
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
ENV NODE_ENV=${NODE_ENV}
|
||||||
|
|
||||||
# COPY package.json yarn.lock ./
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# RUN apt-get update
|
COPY package.json package-lock.json ./
|
||||||
# RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
|
||||||
|
|
||||||
# RUN npm i -g yarn --force
|
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||||
|
|
||||||
# RUN yarn install --only=production
|
RUN npm install --only=production
|
||||||
|
|
||||||
# COPY . .
|
COPY . .
|
||||||
|
|
||||||
# COPY --from=development /usr/src/app/dist ./dist
|
COPY --from=development /usr/src/app/dist ./dist
|
||||||
|
|
||||||
# # Clean up commands
|
CMD ["node", "dist/main"]
|
||||||
# RUN apt-get autoremove -y && apt-get clean && \
|
|
||||||
# rm -rf /usr/local/src/*
|
|
||||||
|
|
||||||
# RUN apt-get clean && \
|
|
||||||
# rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
|
|
||||||
# CMD ["node", "dist/main"]
|
|
||||||
2
server/entrypoint.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# npm run typeorm migration:run
|
||||||
|
npm run start:dev
|
||||||
11241
server/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bull": "^0.4.2",
|
"@nestjs/bull": "^0.4.2",
|
||||||
@@ -30,27 +31,29 @@
|
|||||||
"@nestjs/passport": "^8.1.0",
|
"@nestjs/passport": "^8.1.0",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^8.0.0",
|
||||||
"@nestjs/platform-fastify": "^8.2.6",
|
"@nestjs/platform-fastify": "^8.2.6",
|
||||||
|
"@nestjs/platform-socket.io": "^8.2.6",
|
||||||
"@nestjs/typeorm": "^8.0.3",
|
"@nestjs/typeorm": "^8.0.3",
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
"@nestjs/websockets": "^8.2.6",
|
||||||
"@tensorflow/tfjs": "^3.13.0",
|
"@socket.io/redis-adapter": "^7.1.0",
|
||||||
"@tensorflow/tfjs-converter": "^3.13.0",
|
"axios": "^0.26.0",
|
||||||
"@tensorflow/tfjs-core": "^3.13.0",
|
|
||||||
"@tensorflow/tfjs-node": "^3.13.0",
|
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"bull": "^4.4.0",
|
"bull": "^4.4.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"dotenv": "^14.2.0",
|
"dotenv": "^14.2.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"joi": "^17.5.0",
|
"joi": "^17.5.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"passport": "^0.5.2",
|
"passport": "^0.5.2",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"pg": "^8.7.1",
|
"pg": "^8.7.1",
|
||||||
|
"redis": "^3.1.2",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0",
|
||||||
"sharp": "^0.29.3",
|
"sharp": "0.28",
|
||||||
|
"socket.io-redis": "^6.1.1",
|
||||||
"systeminformation": "^5.11.0",
|
"systeminformation": "^5.11.0",
|
||||||
"typeorm": "^0.2.41"
|
"typeorm": "^0.2.41"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
# events {
|
||||||
|
# worker_connections 1000;
|
||||||
|
# }
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
|
||||||
client_max_body_size 50000M;
|
client_max_body_size 50000M;
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -10,10 +21,14 @@ server {
|
|||||||
proxy_buffers 64 4k;
|
proxy_buffers 64 4k;
|
||||||
proxy_force_ranges on;
|
proxy_force_ranges on;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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;
|
proxy_pass http://immich_server:3000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,33 +12,31 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Response,
|
Response,
|
||||||
Headers,
|
Headers,
|
||||||
BadRequestException,
|
Delete,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
import { AssetService } from './asset.service';
|
import { AssetService } from './asset.service';
|
||||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { multerOption } from '../../config/multer-option.config';
|
import { multerOption } from '../../config/multer-option.config';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
import { createReadStream } from 'fs';
|
|
||||||
import { ServeFileDto } from './dto/serve-file.dto';
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
||||||
import { AssetType } from './entities/asset.entity';
|
import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||||
import { Response as Res } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { promisify } from 'util';
|
|
||||||
import { stat } from 'fs';
|
|
||||||
import { pipeline } from 'stream';
|
|
||||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||||
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
const fileInfo = promisify(stat);
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('asset')
|
@Controller('asset')
|
||||||
export class AssetController {
|
export class AssetController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetService: AssetService,
|
private assetService: AssetService,
|
||||||
private readonly assetOptimizeService: AssetOptimizeService,
|
private assetOptimizeService: AssetOptimizeService,
|
||||||
|
private backgroundTaskService: BackgroundTaskService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@@ -53,6 +51,7 @@ export class AssetController {
|
|||||||
|
|
||||||
if (savedAsset && savedAsset.type == AssetType.IMAGE) {
|
if (savedAsset && savedAsset.type == AssetType.IMAGE) {
|
||||||
await this.assetOptimizeService.resizeImage(savedAsset);
|
await this.assetOptimizeService.resizeImage(savedAsset);
|
||||||
|
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedAsset && savedAsset.type == AssetType.VIDEO) {
|
if (savedAsset && savedAsset.type == AssetType.VIDEO) {
|
||||||
@@ -70,75 +69,17 @@ export class AssetController {
|
|||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@Query(ValidationPipe) query: ServeFileDto,
|
@Query(ValidationPipe) query: ServeFileDto,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
let file = null;
|
return this.assetService.serveFile(authUser, query, res, headers);
|
||||||
const asset = await this.assetService.findOne(authUser, query.did, query.aid);
|
}
|
||||||
|
|
||||||
// Handle Sending Images
|
@Get('/searchTerm')
|
||||||
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
|
||||||
res.set({
|
return this.assetService.getAssetSearchTerm(authUser);
|
||||||
'Content-Type': asset.mimeType,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (query.isThumb === 'false' || !query.isThumb) {
|
@Post('/search')
|
||||||
file = createReadStream(asset.originalPath);
|
async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
|
||||||
} else {
|
return this.assetService.searchAsset(authUser, searchAssetDto);
|
||||||
file = createReadStream(asset.resizePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StreamableFile(file);
|
|
||||||
} else if (asset.type == AssetType.VIDEO) {
|
|
||||||
// Handle Handling Video
|
|
||||||
const { size } = await fileInfo(asset.originalPath);
|
|
||||||
const range = headers.range;
|
|
||||||
|
|
||||||
if (range) {
|
|
||||||
/** Extracting Start and End value from Range Header */
|
|
||||||
let [start, end] = range.replace(/bytes=/, '').split('-');
|
|
||||||
start = parseInt(start, 10);
|
|
||||||
end = end ? parseInt(end, 10) : size - 1;
|
|
||||||
|
|
||||||
if (!isNaN(start) && isNaN(end)) {
|
|
||||||
start = start;
|
|
||||||
end = size - 1;
|
|
||||||
}
|
|
||||||
if (isNaN(start) && !isNaN(end)) {
|
|
||||||
start = size - end;
|
|
||||||
end = size - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle unavailable range request
|
|
||||||
if (start >= size || end >= size) {
|
|
||||||
console.error('Bad Request');
|
|
||||||
// Return the 416 Range Not Satisfiable.
|
|
||||||
res.status(416).set({
|
|
||||||
'Content-Range': `bytes */${size}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new BadRequestException('Bad Request Range');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sending Partial Content With HTTP Code 206 */
|
|
||||||
|
|
||||||
res.status(206).set({
|
|
||||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Content-Length': end - start + 1,
|
|
||||||
'Content-Type': asset.mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
|
|
||||||
|
|
||||||
return new StreamableFile(videoStream);
|
|
||||||
} else {
|
|
||||||
res.set({
|
|
||||||
'Content-Type': asset.mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new StreamableFile(createReadStream(asset.originalPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('SHOULD NOT BE HERE');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/new')
|
@Get('/new')
|
||||||
@@ -151,8 +92,38 @@ export class AssetController {
|
|||||||
return await this.assetService.getAllAssets(authUser, query);
|
return await this.assetService.getAllAssets(authUser, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/')
|
||||||
|
async getAllAssetsNoPagination(@GetAuthUser() authUser: AuthUserDto) {
|
||||||
|
return await this.assetService.getAllAssetsNoPagination(authUser);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('/:deviceId')
|
@Get('/:deviceId')
|
||||||
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
|
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
|
||||||
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
|
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/assetById/:assetId')
|
||||||
|
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
|
||||||
|
return this.assetService.getAssetById(authUser, assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/')
|
||||||
|
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
|
||||||
|
const deleteAssetList: AssetEntity[] = [];
|
||||||
|
|
||||||
|
assetIds.ids.forEach(async (id) => {
|
||||||
|
const assets = await this.assetService.getAssetById(authUser, id);
|
||||||
|
deleteAssetList.push(assets);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.assetService.deleteAssetById(authUser, assetIds);
|
||||||
|
|
||||||
|
result.forEach((res) => {
|
||||||
|
deleteAssetList.filter((a) => a.id == res.id && res.status == 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { AssetEntity } from './entities/asset.entity';
|
|||||||
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
|
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
|
||||||
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||||
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,7 +20,7 @@ import { BullModule } from '@nestjs/bull';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'machine-learning',
|
name: 'background-task',
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
@@ -27,9 +29,10 @@ import { BullModule } from '@nestjs/bull';
|
|||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity]),
|
||||||
ImageOptimizeModule,
|
ImageOptimizeModule,
|
||||||
|
BackgroundTaskModule,
|
||||||
],
|
],
|
||||||
controllers: [AssetController],
|
controllers: [AssetController],
|
||||||
providers: [AssetService, AssetOptimizeService],
|
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
export class AssetModule {}
|
export class AssetModule {}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { MoreThan, Repository } from 'typeorm';
|
import { MoreThan, Repository } from 'typeorm';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||||
import { AssetEntity, AssetType } from './entities/asset.entity';
|
import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||||
import _ from 'lodash';
|
import _, { result } from 'lodash';
|
||||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||||
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
|
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
|
||||||
import { Greater } from '@tensorflow/tfjs-core';
|
import { createReadStream, stat } from 'fs';
|
||||||
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
|
import { Response as Res } from 'express';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
|
||||||
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetService {
|
export class AssetService {
|
||||||
@@ -53,6 +60,20 @@ export class AssetService {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAllAssetsNoPagination(authUser: AuthUserDto) {
|
||||||
|
try {
|
||||||
|
const assets = await this.assetRepository
|
||||||
|
.createQueryBuilder('a')
|
||||||
|
.where('a."userId" = :userId', { userId: authUser.id })
|
||||||
|
.orderBy('a."createdAt"::date', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error(e, 'getAllAssets');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
|
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
|
||||||
try {
|
try {
|
||||||
const assets = await this.assetRepository
|
const assets = await this.assetRepository
|
||||||
@@ -113,4 +134,168 @@ export class AssetService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAssetById(authUser: AuthUserDto, assetId: string) {
|
||||||
|
return await this.assetRepository.findOne({
|
||||||
|
where: {
|
||||||
|
userId: authUser.id,
|
||||||
|
id: assetId,
|
||||||
|
},
|
||||||
|
relations: ['exifInfo'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
|
||||||
|
let file = null;
|
||||||
|
const asset = await this.findOne(authUser, query.did, query.aid);
|
||||||
|
|
||||||
|
// Handle Sending Images
|
||||||
|
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isThumb === 'false' || !query.isThumb) {
|
||||||
|
file = createReadStream(asset.originalPath);
|
||||||
|
} else {
|
||||||
|
file = createReadStream(asset.resizePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
file.on('error', (error) => {
|
||||||
|
Logger.log(`Cannot create read stream ${error}`);
|
||||||
|
return new BadRequestException('Cannot Create Read Stream');
|
||||||
|
});
|
||||||
|
return new StreamableFile(file);
|
||||||
|
} else if (asset.type == AssetType.VIDEO) {
|
||||||
|
// Handle Handling Video
|
||||||
|
const { size } = await fileInfo(asset.originalPath);
|
||||||
|
const range = headers.range;
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
/** Extracting Start and End value from Range Header */
|
||||||
|
let [start, end] = range.replace(/bytes=/, '').split('-');
|
||||||
|
start = parseInt(start, 10);
|
||||||
|
end = end ? parseInt(end, 10) : size - 1;
|
||||||
|
|
||||||
|
if (!isNaN(start) && isNaN(end)) {
|
||||||
|
start = start;
|
||||||
|
end = size - 1;
|
||||||
|
}
|
||||||
|
if (isNaN(start) && !isNaN(end)) {
|
||||||
|
start = size - end;
|
||||||
|
end = size - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unavailable range request
|
||||||
|
if (start >= size || end >= size) {
|
||||||
|
console.error('Bad Request');
|
||||||
|
// Return the 416 Range Not Satisfiable.
|
||||||
|
res.status(416).set({
|
||||||
|
'Content-Range': `bytes */${size}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new BadRequestException('Bad Request Range');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sending Partial Content With HTTP Code 206 */
|
||||||
|
|
||||||
|
res.status(206).set({
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': end - start + 1,
|
||||||
|
'Content-Type': asset.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
|
||||||
|
|
||||||
|
return new StreamableFile(videoStream);
|
||||||
|
} else {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new StreamableFile(createReadStream(asset.originalPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
|
||||||
|
let result = [];
|
||||||
|
|
||||||
|
const target = assetIds.ids;
|
||||||
|
for (let assetId of target) {
|
||||||
|
const res = await this.assetRepository.delete({
|
||||||
|
id: assetId,
|
||||||
|
userId: authUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.affected) {
|
||||||
|
result.push({
|
||||||
|
id: assetId,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
id: assetId,
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssetSearchTerm(authUser: AuthUserDto): Promise<String[]> {
|
||||||
|
const possibleSearchTerm = new Set<String>();
|
||||||
|
const rows = await this.assetRepository.query(
|
||||||
|
`
|
||||||
|
select distinct si.tags, e.orientation, e."lensModel", e.make, e.model , a.type
|
||||||
|
from assets a
|
||||||
|
left join exif e on a.id = e."assetId"
|
||||||
|
left join smart_info si on a.id = si."assetId"
|
||||||
|
where a."userId" = $1;
|
||||||
|
`,
|
||||||
|
[authUser.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
// tags
|
||||||
|
row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase()));
|
||||||
|
|
||||||
|
// asset's tyoe
|
||||||
|
possibleSearchTerm.add(row['type']?.toLowerCase());
|
||||||
|
|
||||||
|
// image orientation
|
||||||
|
possibleSearchTerm.add(row['orientation']?.toLowerCase());
|
||||||
|
|
||||||
|
// Lens model
|
||||||
|
possibleSearchTerm.add(row['lensModel']?.toLowerCase());
|
||||||
|
|
||||||
|
// Make and model
|
||||||
|
possibleSearchTerm.add(row['make']?.toLowerCase());
|
||||||
|
possibleSearchTerm.add(row['model']?.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(possibleSearchTerm).filter((x) => x != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) {
|
||||||
|
const query = `
|
||||||
|
SELECT a.*
|
||||||
|
FROM assets a
|
||||||
|
LEFT JOIN smart_info si ON a.id = si."assetId"
|
||||||
|
LEFT JOIN exif e ON a.id = e."assetId"
|
||||||
|
|
||||||
|
WHERE a."userId" = $1
|
||||||
|
AND
|
||||||
|
(
|
||||||
|
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||||
|
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
server/src/api-v1/asset/dto/create-exif.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateExifDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
assetId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
make: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
model: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
imageName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
exifImageWidth: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
exifImageHeight: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
fileSizeInByte: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
orientation: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
dateTimeOriginal: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
modifiedDate: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
lensModel: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
fNumber: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
focalLenght: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
iso: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
exposureTime: number;
|
||||||
|
}
|
||||||
6
server/src/api-v1/asset/dto/delete-asset.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
6
server/src/api-v1/asset/dto/search-asset.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class SearchAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||