Compare commits

...

46 Commits

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

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

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

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

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

Remove the triggers you don't want.

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

* Create dependabot.yml

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

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

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

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

@@ -0,0 +1,46 @@
name: Build+push Immich
on:
# Triggers the workflow on push or pull request events but only for the main branch
#schedule:
# * is a special character in YAML so you have to quote this string
#- cron: '0 0 * * *'
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
buildandpush:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2.4.0
with:
ref: "main" # branch
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Immich
uses: docker/build-push-action@v2.9.0
with:
context: ./server
file: ./server/Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/arm/v7,linux/amd64,linux/arm64
pull: true
push: true
tags: |
altran1502/immich-server:latest

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

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

View File

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

View File

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

View File

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

BIN
design/sc1.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

BIN
design/sc2.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
design/sc3.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

BIN
design/sc4.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

BIN
design/sc5.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

View File

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

1
docker/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

3
machine_learning/.gitignore vendored Normal file
View File

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

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

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

View File

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

View File

@@ -1,122 +1,158 @@
{
"images" : [
{
"size" : "20x20",
"filename" : "immich-logo-1024-20@2x.png",
"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",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
"scale" : "3x",
"size" : "20x20"
},
{
"size" : "29x29",
"filename" : "immich-logo-1024-29.png",
"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",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "29x29"
},
{
"size" : "29x29",
"filename" : "immich-logo-1024-29@3x.png",
"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",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "40x40"
},
{
"size" : "40x40",
"filename" : "immich-logo-1024-40@3x.png",
"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",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "60x60"
},
{
"size" : "60x60",
"filename" : "immich-logo-1024-60@3x.png",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
"scale" : "3x",
"size" : "60x60"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"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",
"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" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final ImmichAsset asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage(
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
useEffect(() {
getAssetExif();
return null;
}, []);
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
},
),
body: SafeArea(
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Wrap(
spacing: 32,
runSpacing: 32,
alignment: WrapAlignment.center,
children: [
const Text(
"Failed To Render Image - Possibly Corrupted Data",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.white),
),
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
placeholder: (context, url) {
return CachedNetworkImage(
cacheKey: thumbnailUrl,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: Colors.grey[300],
),
);
},
),
),
),
),
);
}
}

View File

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

View File

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

View File

@@ -1,60 +1,74 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package: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/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
final imagePerPage = 100;
class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
final AssetService _assetService = AssetService();
final DeviceInfoService _deviceInfoService = DeviceInfoService();
final Ref ref;
AssetNotifier() : super([]);
late String? nextPageKey = "";
bool isFetching = false;
AssetNotifier(this.ref) : super([]);
getImmichAssets() async {
GetAllAssetResponse? res = await _assetService.getAllAsset();
nextPageKey = res?.nextPageKey;
getAllAsset() async {
List<ImmichAsset>? allAssets = await _assetService.getAllAsset();
if (res != null) {
for (var assets in res.data) {
state = [...state, assets];
}
}
}
getMoreAsset() async {
if (nextPageKey != null && !isFetching) {
isFetching = true;
GetAllAssetResponse? res = await _assetService.getMoreAsset(nextPageKey);
if (res != null) {
nextPageKey = res.nextPageKey;
List<ImmichAssetGroupByDate> previousState = state;
List<ImmichAssetGroupByDate> currentState = [];
for (var assets in res.data) {
currentState = [...currentState, assets];
}
if (previousState.last.date == currentState.first.date) {
previousState.last.assets = [...previousState.last.assets, ...currentState.first.assets];
state = [...previousState, ...currentState.sublist(1)];
} else {
state = [...previousState, ...currentState];
}
}
isFetching = false;
if (allAssets != null) {
state = allAssets;
}
}
clearAllAsset() {
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<ImmichAssetGroupByDate>>((ref) {
return AssetNotifier();
final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
return AssetNotifier(ref);
});
final assetGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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';
class LoginPage extends HookConsumerWidget {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,24 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
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 {
try {
var dio = Dio();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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