Compare commits
25 Commits
v1.3.0-dev
...
v1.6.0_10-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7dff229db | ||
|
|
8e80825b4f | ||
|
|
a1481c1113 | ||
|
|
3bdcdef198 | ||
|
|
b69f6e0df7 | ||
|
|
be2794a372 | ||
|
|
2ff25b49f4 | ||
|
|
135d72d4cd | ||
|
|
90ef64efa3 | ||
|
|
60df387459 | ||
|
|
fc1acf6f01 | ||
|
|
cfc5229964 | ||
|
|
f9ddeac265 | ||
|
|
8d7c576037 | ||
|
|
fccdbdd66a | ||
|
|
23ba651705 | ||
|
|
ac0ad98b55 | ||
|
|
9cbd5d1b0c | ||
|
|
80fd664cc8 | ||
|
|
041c711cb9 | ||
|
|
dd9c5244fd | ||
|
|
fe693db84f | ||
|
|
5c9d3cd08b | ||
|
|
725ab5622f | ||
|
|
e9acd21733 |
64
.github/workflows/build_push_docker_latest.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Build and Push Docker Image - Latest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
|
||||
build_and_push_server_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-server:latest
|
||||
|
||||
build_and_push_microservice_latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and Push Microservices
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./microservices
|
||||
file: ./microservices/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
altran1502/immich-microservices:latest
|
||||
46
.github/workflows/build_push_server.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Build Server
|
||||
|
||||
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@v3
|
||||
with:
|
||||
ref: "main" # branch
|
||||
# https://github.com/docker/setup-qemu-action#usage
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
# https://github.com/docker/login-action#docker-hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
altran1502/immich-server:latest
|
||||
95
.github/workflows/build_push_server_release.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Build Server - Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_and_push_server_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: altran1502/immich-server
|
||||
|
||||
- name: 'Get Previous tag'
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-server release
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.previoustag.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build_and_push_microservice_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: altran1502/immich-microservices
|
||||
|
||||
- name: 'Get Previous tag'
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
fallback: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push immich-microservices release
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./microservices
|
||||
file: ./microservices/Dockerfile
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.previoustag.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
4
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
12
Makefile
@@ -1,8 +1,14 @@
|
||||
dev:
|
||||
docker-compose -f ./docker/docker-compose.yml up
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-update:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --remove-orphans
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
|
||||
|
||||
prod:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
prod-scale:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --scale immich_microservices=3 --remove-orphans
|
||||
@@ -1,13 +1,17 @@
|
||||
# Deployment checklist for iOS/Android/Server
|
||||
|
||||
[] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
|
||||
[] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
|
||||
[] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
|
||||
[] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
|
||||
[] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
|
||||
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
|
||||
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
|
||||
|
||||
All of the version should be the same.
|
||||
@@ -34,7 +34,8 @@ Loading ~4000 images/videos
|
||||
<p align="left">
|
||||
<img src="design/nsc1.png" width="150" title="Login With Custom URL">
|
||||
<img src="design/nsc2.png" width="150" title="Backup Setting Info">
|
||||
<img src="design/nsc3.png" width="150" title="Multiple seelct">
|
||||
<img src="design/nsc3.png" width="150" title="Multiple select">
|
||||
<img src="design/nsc4.jpeg" width="150" title="Curated Search Info">
|
||||
<img src="design/nsc6.png" width="150" title="EXIF Info">
|
||||
|
||||
</p>
|
||||
@@ -55,11 +56,13 @@ This project is under heavy development, there will be continous functions, feat
|
||||
- Extract and display EXIF info.
|
||||
- Real-time render from multi-device upload event.
|
||||
- Image Tagging/Classification based on ImageNet dataset
|
||||
- Object detection based on COCO SSD.
|
||||
- Search assets based on tags and exif data (lens, make, model, orientation)
|
||||
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
|
||||
- [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
||||
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
||||
- Show asset's location information on map (OpenStreetMap).
|
||||
- Show curated places on the search page
|
||||
- Show curated objects on the search page
|
||||
|
||||
# Development
|
||||
|
||||
@@ -69,7 +72,7 @@ You can use docker compose for development, there are several services that comp
|
||||
2. PostgreSQL
|
||||
3. Redis
|
||||
4. Nginx
|
||||
5. TensorFlow and Keras
|
||||
5. TensorFlow
|
||||
|
||||
## Populate .env file
|
||||
|
||||
|
||||
BIN
design/nsc4.jpeg
Normal file
|
After Width: | Height: | Size: 406 KiB |
@@ -1,6 +1,3 @@
|
||||
# STAGE
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
89
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.6.0
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: 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
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices-dev:1.6.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- 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
|
||||
|
||||
networks:
|
||||
immich_network:
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -2,11 +2,10 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.3.0
|
||||
image: immich-server-dev:1.6.0
|
||||
build:
|
||||
context: ../server
|
||||
target: development
|
||||
dockerfile: ../server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3000"
|
||||
@@ -22,6 +21,33 @@ services:
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices-dev:1.6.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: Dockerfile
|
||||
command: npm run start:dev
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- database
|
||||
- immich_server
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
@@ -60,35 +86,6 @@ services:
|
||||
depends_on:
|
||||
- immich_server
|
||||
|
||||
immich_tf_fastapi:
|
||||
container_name: immich_tf_fastapi
|
||||
image: tensor_flow_fastapi:1.0.0
|
||||
restart: always
|
||||
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
|
||||
build:
|
||||
context: ../machine_learning
|
||||
target: 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:
|
||||
|
||||
@@ -2,26 +2,45 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.3.0
|
||||
image: immich-server:1.6.0
|
||||
build:
|
||||
context: ../server
|
||||
target: development
|
||||
dockerfile: ../server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
# 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
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
restart: unless-stopped
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices:1.6.0
|
||||
build:
|
||||
context: ../microservices
|
||||
dockerfile: Dockerfile
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
@@ -61,28 +80,7 @@ services:
|
||||
depends_on:
|
||||
- immich_server
|
||||
|
||||
immich_tf_fastapi:
|
||||
container_name: immich_tf_fastapi
|
||||
image: tensor_flow_fastapi:1.0.0
|
||||
restart: always
|
||||
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
|
||||
build:
|
||||
context: ../machine_learning
|
||||
target: cpu
|
||||
dockerfile: ../machine_learning/Dockerfile
|
||||
volumes:
|
||||
- ../machine_learning/app:/code/app
|
||||
- ${UPLOAD_LOCATION}:/code/app/upload
|
||||
ports:
|
||||
- 2285:8000
|
||||
expose:
|
||||
- "8000"
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
networks:
|
||||
immich_network:
|
||||
volumes:
|
||||
pgdata:
|
||||
pgdata:
|
||||
@@ -13,7 +13,7 @@ server {
|
||||
client_max_body_size 50000M;
|
||||
|
||||
listen 80;
|
||||
|
||||
access_log off;
|
||||
location / {
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
|
||||
3
fastlane/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
This directory exists because of the F-Droid build process. F-Droid is using the same directory structure as Fastlane for the app metadata.
|
||||
|
||||
Because F-Droid expects the metadata to be located in the root of the repository we need to have this symlink.
|
||||
1
fastlane/metadata
Symbolic link
@@ -0,0 +1 @@
|
||||
../mobile/android/fastlane/metadata
|
||||
4
microservices/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
upload/
|
||||
dist/
|
||||
|
||||
24
microservices/.eslintrc.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
35
microservices/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
4
microservices/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
16
microservices/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:16-bullseye-slim
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
4
microservices/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
# Microservices for Immich
|
||||
|
||||
## Image Classifier
|
||||
2
microservices/entrypoint.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
# npm run typeorm migration:run
|
||||
npm run build && npm run start:prod
|
||||
4
microservices/nest-cli.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
17323
microservices/package-lock.json
generated
Normal file
83
microservices/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "nest_microservices",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/mapped-types": "^1.0.1",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/typeorm": "^8.0.3",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||
"@tensorflow/tfjs": "^3.15.0",
|
||||
"@tensorflow/tfjs-converter": "^3.15.0",
|
||||
"@tensorflow/tfjs-core": "^3.15.0",
|
||||
"@tensorflow/tfjs-node": "^3.15.0",
|
||||
"@tensorflow/tfjs-node-gpu": "^3.15.0",
|
||||
"@trpc/server": "^9.20.3",
|
||||
"pg": "^8.7.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"typeorm": "^0.2.45"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.2.4",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.5",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
16
microservices/src/app.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
||||
import { databaseConfig } from './config/database.config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
ImageClassifierModule,
|
||||
ObjectDetectionModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
11
microservices/src/config/database.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const databaseConfig: TypeOrmModuleOptions = {
|
||||
type: 'postgres',
|
||||
host: 'immich_postgres',
|
||||
port: 5432,
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE_NAME,
|
||||
synchronize: false,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ImageClassifierService } from './image-classifier.service';
|
||||
|
||||
@Controller('image-classifier')
|
||||
export class ImageClassifierController {
|
||||
constructor(
|
||||
private readonly imageClassifierService: ImageClassifierService,
|
||||
) {}
|
||||
|
||||
@Post('/tagImage')
|
||||
async tagImage(@Body('thumbnailPath') thumbnailPath: string) {
|
||||
return await this.imageClassifierService.tagImage(thumbnailPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageClassifierService } from './image-classifier.service';
|
||||
import { ImageClassifierController } from './image-classifier.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ImageClassifierController],
|
||||
providers: [ImageClassifierService],
|
||||
})
|
||||
export class ImageClassifierModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as mobilenet from '@tensorflow-models/mobilenet';
|
||||
import * as cocoSsd from '@tensorflow-models/coco-ssd';
|
||||
import * as tf from '@tensorflow/tfjs-node';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class ImageClassifierService {
|
||||
private readonly MOBILENET_VERSION = 2;
|
||||
private readonly MOBILENET_ALPHA = 1.0;
|
||||
|
||||
private mobileNetModel: mobilenet.MobileNet;
|
||||
|
||||
constructor() {
|
||||
Logger.log(
|
||||
`Running Node TensorFlow Version : ${tf.version['tfjs']}`,
|
||||
'ImageClassifier',
|
||||
);
|
||||
mobilenet
|
||||
.load({
|
||||
version: this.MOBILENET_VERSION,
|
||||
alpha: this.MOBILENET_ALPHA,
|
||||
})
|
||||
.then((mobilenetModel) => (this.mobileNetModel = mobilenetModel));
|
||||
}
|
||||
|
||||
async tagImage(thumbnailPath: string) {
|
||||
try {
|
||||
const isExist = fs.existsSync(thumbnailPath);
|
||||
if (isExist) {
|
||||
const tags = [];
|
||||
const image = fs.readFileSync(thumbnailPath);
|
||||
const decodedImage = tf.node.decodeImage(image, 3) as tf.Tensor3D;
|
||||
const predictions = await this.mobileNetModel.classify(decodedImage);
|
||||
|
||||
for (const prediction of predictions) {
|
||||
if (prediction.probability >= 0.1) {
|
||||
tags.push(...prediction.className.split(',').map((e) => e.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
tf.dispose(decodedImage);
|
||||
return tags;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error reading file ', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
microservices/src/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
await app.listen(3001, () => {
|
||||
if (process.env.NODE_ENV == 'development') {
|
||||
Logger.log(
|
||||
'Running Immich Microservices in DEVELOPMENT environment',
|
||||
'IMMICH MICROSERVICES',
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV == 'production') {
|
||||
Logger.log(
|
||||
'Running Immich Microservices in PRODUCTION environment',
|
||||
'IMMICH MICROSERVICES',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ObjectDetectionService } from './object-detection.service';
|
||||
|
||||
@Controller('object-detection')
|
||||
export class ObjectDetectionController {
|
||||
constructor(
|
||||
private readonly objectDetectionService: ObjectDetectionService,
|
||||
) {}
|
||||
|
||||
@Post('/detectObject')
|
||||
async detectObject(@Body('thumbnailPath') thumbnailPath: string) {
|
||||
return await this.objectDetectionService.detectObject(thumbnailPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ObjectDetectionService } from './object-detection.service';
|
||||
import { ObjectDetectionController } from './object-detection.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ObjectDetectionController],
|
||||
providers: [ObjectDetectionService],
|
||||
})
|
||||
export class ObjectDetectionModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as cocoSsd from '@tensorflow-models/coco-ssd';
|
||||
import * as tf from '@tensorflow/tfjs-node';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectDetectionService {
|
||||
private cocoSsdModel: cocoSsd.ObjectDetection;
|
||||
|
||||
constructor() {
|
||||
Logger.log(
|
||||
`Running Node TensorFlow Version : ${tf.version['tfjs']}`,
|
||||
'ObjectDetection',
|
||||
);
|
||||
cocoSsd.load().then((model) => (this.cocoSsdModel = model));
|
||||
}
|
||||
async detectObject(thumbnailPath: string) {
|
||||
try {
|
||||
const isExist = fs.existsSync(thumbnailPath);
|
||||
if (isExist) {
|
||||
const tags = new Set();
|
||||
const image = fs.readFileSync(thumbnailPath);
|
||||
const decodedImage = tf.node.decodeImage(image, 3) as tf.Tensor3D;
|
||||
const predictions = await this.cocoSsdModel.detect(decodedImage);
|
||||
|
||||
for (const result of predictions) {
|
||||
if (result.score > 0.5) {
|
||||
tags.add(result.class);
|
||||
}
|
||||
}
|
||||
|
||||
tf.dispose(decodedImage);
|
||||
return [...tags];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error reading file ', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
microservices/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
// End to End test
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
9
microservices/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
microservices/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
21
microservices/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ android {
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 20
|
||||
minSdkVersion 21
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -20,4 +20,7 @@
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,2 @@
|
||||
* Accepting webp file format
|
||||
* Fixed backup stop when an asset is of wrong file type. The app will now skip that asset and try its best to perform the backup operation on the rest of the assets.
|
||||
@@ -0,0 +1 @@
|
||||
* Added curated locations and objects on the search page
|
||||
@@ -0,0 +1,2 @@
|
||||
* User can now download assets to local device
|
||||
* Increased the font size for curated image thumbnail information on the seach page
|
||||
@@ -0,0 +1 @@
|
||||
* Added inline font, remove google-font dependency in pubspec.
|
||||
@@ -0,0 +1,21 @@
|
||||
This is a client app for the self-hostable Immich Server (which can be found with the app's source repo). You will need to run/manage the server on your own in order to use the app.
|
||||
|
||||
Once set up, this app can be used as photo and video backup solution directly from your mobile phone.
|
||||
|
||||
<b>Features:</b>
|
||||
|
||||
* Upload and view assets(videos/images).
|
||||
* Multi-user supported.
|
||||
* 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 Tagging/Classification based on ImageNet dataset
|
||||
* Object detection based on COCO SSD.
|
||||
* Search assets based on tags and exif data (lens, make, model, orientation)
|
||||
* Upload assets from your local computer/server using <a href='https://www.npmjs.com/package/immich' target='_blank' rel='nofollow'>immich cli tools</a>
|
||||
* [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
||||
* Show asset's location information on map (OpenStreetMap).
|
||||
* Show curated places on the search page
|
||||
* Show curated objects on the search page
|
||||
|
After Width: | Height: | Size: 48 KiB |
BIN
mobile/android/fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1 @@
|
||||
This is a client app for the self-hostable Immich Server
|
||||
1
mobile/android/fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Immich
|
||||
@@ -4,3 +4,4 @@ distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
||||
distributionSha256Sum=0080de8491f0918e4f529a6db6820fa0b9e818ee2386117f4394f95feb1d5583
|
||||
BIN
mobile/fonts/SnowburstOne.ttf
Normal file
BIN
mobile/fonts/WorkSans-Italic.ttf
Normal file
BIN
mobile/fonts/WorkSans.ttf
Normal file
@@ -13,7 +13,7 @@ PODS:
|
||||
- Flutter
|
||||
- path_provider_ios (0.0.1):
|
||||
- Flutter
|
||||
- photo_manager (1.0.0):
|
||||
- photo_manager (2.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SAMKeychain (1.5.3)
|
||||
@@ -70,7 +70,7 @@ SPEC CHECKSUMS:
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
|
||||
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
|
||||
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
@@ -79,4 +79,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 05c3056158482c567a3e0cdab1351ceeee238a07
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
COCOAPODS: 1.11.3
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
NEW_SETTING = "";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -425,7 +425,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
NEW_SETTING = "";
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -475,7 +475,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
NEW_SETTING = "";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -1,66 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Immich</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Immich</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false />
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,11 +19,11 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.3.1"
|
||||
version_number: "1.6.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
)
|
||||
increment_build_number({
|
||||
build_number: latest_testflight_build_number + 1
|
||||
})
|
||||
build_app(scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
xcargs: "-allowProvisioningUpdates")
|
||||
@@ -32,19 +32,4 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
increment_version_number(
|
||||
version_number: "1.3.1"
|
||||
)
|
||||
increment_build_number({
|
||||
build_number: latest_testflight_build_number + 1
|
||||
})
|
||||
build_app(scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
xcargs: "-allowProvisioningUpdates")
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'constants/hive_box.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
void main() async {
|
||||
await Hive.initFlutter();
|
||||
@@ -94,9 +93,11 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
textTheme: GoogleFonts.workSansTextTheme(
|
||||
Theme.of(context).textTheme.apply(fontSizeFactor: 1.0),
|
||||
),
|
||||
// textTheme: GoogleFonts.workSansTextTheme(
|
||||
// Theme.of(context).textTheme.apply(fontSizeFactor: 1.0),
|
||||
// ),
|
||||
fontFamily: 'WorkSans',
|
||||
snackBarTheme: const SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: 'WorkSans')),
|
||||
scaffoldBackgroundColor: const Color(0xFFf6f8fe),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum DownloadAssetStatus { idle, loading, success, error }
|
||||
|
||||
class ImageViewerPageState {
|
||||
final bool isBottomSheetEnable;
|
||||
// enum
|
||||
final DownloadAssetStatus downloadAssetStatus;
|
||||
|
||||
ImageViewerPageState({
|
||||
required this.isBottomSheetEnable,
|
||||
required this.downloadAssetStatus,
|
||||
});
|
||||
|
||||
ImageViewerPageState copyWith({
|
||||
bool? isBottomSheetEnable,
|
||||
DownloadAssetStatus? downloadAssetStatus,
|
||||
}) {
|
||||
return ImageViewerPageState(
|
||||
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
|
||||
downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'isBottomSheetEnable': isBottomSheetEnable,
|
||||
};
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
|
||||
return ImageViewerPageState(
|
||||
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
|
||||
downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,15 +37,15 @@ class ImageViewerPageState {
|
||||
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
|
||||
String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
|
||||
return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => isBottomSheetEnable.hashCode;
|
||||
int get hashCode => downloadAssetStatus.hashCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class RequestDownloadAssetInfo {
|
||||
final String assetId;
|
||||
final String deviceId;
|
||||
|
||||
RequestDownloadAssetInfo(this.assetId, this.deviceId);
|
||||
}
|
||||
@@ -1,21 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
|
||||
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
|
||||
final ImageViewerService _imageViewerService = ImageViewerService();
|
||||
|
||||
void toggleBottomSheet() {
|
||||
bool isBottomSheetEnable = state.isBottomSheetEnable;
|
||||
ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
|
||||
|
||||
if (isBottomSheetEnable) {
|
||||
state.copyWith(isBottomSheetEnable: false);
|
||||
void downloadAsset(ImmichAsset asset, BuildContext context) async {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
|
||||
|
||||
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
|
||||
|
||||
if (isSuccess) {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Download Success",
|
||||
toastType: ToastType.success,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
} else {
|
||||
state.copyWith(isBottomSheetEnable: true);
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Download Error",
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
|
||||
}
|
||||
}
|
||||
|
||||
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
|
||||
((ref) => ImageViewerPageStateNotifier()));
|
||||
final imageViewerStateProvider =
|
||||
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class ImageViewerService {
|
||||
Future<bool> downloadAssetToDevice(ImmichAsset asset) async {
|
||||
try {
|
||||
String fileName = p.basename(asset.originalPath);
|
||||
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
Uri filePath =
|
||||
Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
|
||||
|
||||
var res = await http.get(
|
||||
filePath,
|
||||
headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"},
|
||||
);
|
||||
|
||||
final AssetEntity? entity;
|
||||
|
||||
if (asset.type == 'IMAGE') {
|
||||
entity = await PhotoManager.editor.saveImage(
|
||||
res.bodyBytes,
|
||||
title: p.basename(asset.originalPath),
|
||||
);
|
||||
} else {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
File tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
|
||||
}
|
||||
|
||||
if (entity != null) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error saving file $e");
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
|
||||
class DownloadLoadingIndicator extends StatelessWidget {
|
||||
const DownloadLoadingIndicator({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const SpinKitDancingSquare(
|
||||
color: Colors.white,
|
||||
size: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
|
||||
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
|
||||
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar(
|
||||
{Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
|
||||
: super(key: key);
|
||||
|
||||
final ImmichAsset asset;
|
||||
final Function onMoreInfoPressed;
|
||||
final Function onDownloadPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double iconSize = 18.0;
|
||||
|
||||
return AppBar(
|
||||
@@ -29,7 +34,7 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
print("download");
|
||||
onDownloadPressed();
|
||||
},
|
||||
icon: const Icon(Icons.cloud_download_rounded),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,9 @@ 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/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
@@ -25,6 +28,7 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
getAssetExif() async {
|
||||
@@ -42,65 +46,77 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
asset: asset,
|
||||
onMoreInfoPressed: () {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.black,
|
||||
barrierColor: Colors.transparent,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
});
|
||||
backgroundColor: Colors.black,
|
||||
barrierColor: Colors.transparent,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
},
|
||||
);
|
||||
},
|
||||
onDownloadPressed: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 32,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Failed To Render Image - Possibly Corrupted Data",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
errorWidget: (context, url, error) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 32,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Failed To Render Image - Possibly Corrupted Data",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
),
|
||||
placeholder: (context, url) {
|
||||
return CachedNetworkImage(
|
||||
cacheKey: thumbnailUrl,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
),
|
||||
],
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.error,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
placeholder: (context, url) {
|
||||
return CachedNetworkImage(
|
||||
cacheKey: thumbnailUrl,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
placeholderFadeInDuration: const Duration(milliseconds: 0),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||
),
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.error,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: DownloadLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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:chewie/chewie.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class VideoViewerPage extends StatelessWidget {
|
||||
// ignore: must_be_immutable
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
final String videoUrl;
|
||||
final ImmichAsset asset;
|
||||
ImmichAssetWithExif? assetDetail;
|
||||
final AssetService _assetService = AssetService();
|
||||
|
||||
const VideoViewerPage({Key? key, required this.videoUrl}) : super(key: key);
|
||||
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
|
||||
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail = await _assetService.getAssetById(asset.id);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
getAssetExif();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
backgroundColor: Colors.black,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
appBar: TopControlAppBar(
|
||||
asset: asset,
|
||||
onMoreInfoPressed: () {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.black,
|
||||
barrierColor: Colors.transparent,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios)),
|
||||
);
|
||||
},
|
||||
onDownloadPressed: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
child: VideoThumbnailPlayer(
|
||||
url: videoUrl,
|
||||
jwtToken: jwtToken,
|
||||
child: Stack(
|
||||
children: [
|
||||
VideoThumbnailPlayer(
|
||||
url: videoUrl,
|
||||
jwtToken: jwtToken,
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: DownloadLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
|
||||
@@ -79,12 +78,11 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
),
|
||||
title: Text(
|
||||
'IMMICH',
|
||||
style: GoogleFonts.snowburstOne(
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -121,8 +119,12 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
),
|
||||
child: const Icon(Icons.backup_rounded)),
|
||||
tooltip: 'Backup Controller',
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
onPressed: () async {
|
||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
|
||||
if (onPop != null && onPop == true) {
|
||||
onPopBack!();
|
||||
}
|
||||
},
|
||||
),
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
|
||||
@@ -65,8 +65,8 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
} else {
|
||||
AutoRouter.of(context).push(
|
||||
VideoViewerRoute(
|
||||
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
|
||||
),
|
||||
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
|
||||
asset: asset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ class HomePage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
void reloadAllAsset() {
|
||||
ref.read(assetProvider.notifier).getAllAsset();
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (assetGroupByDateTime.isNotEmpty) {
|
||||
int? lastMonth;
|
||||
@@ -86,7 +90,9 @@ class HomePage extends HookConsumerWidget {
|
||||
child: null,
|
||||
),
|
||||
)
|
||||
: const ImmichSliverAppBar(),
|
||||
: ImmichSliverAppBar(
|
||||
onPopBack: reloadAllAsset,
|
||||
),
|
||||
duration: const Duration(milliseconds: 350),
|
||||
),
|
||||
..._imageGridGroup
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
@@ -33,9 +32,12 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
'IMMICH',
|
||||
style: GoogleFonts.snowburstOne(
|
||||
textStyle:
|
||||
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 48,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
EmailInput(controller: usernameController),
|
||||
PasswordInput(controller: passwordController),
|
||||
@@ -78,7 +80,7 @@ class EmailInput extends StatelessWidget {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
|
||||
const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,9 +130,10 @@ class LoginButton extends ConsumerWidget {
|
||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Error logging you in, check server url, email and password!",
|
||||
toastType: ToastType.error);
|
||||
context: context,
|
||||
msg: "Error logging you in, check server url, email and password!",
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Login"));
|
||||
|
||||
80
mobile/lib/modules/search/models/curated_object.model.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CuratedObject {
|
||||
final String id;
|
||||
final String object;
|
||||
final String resizePath;
|
||||
final String deviceAssetId;
|
||||
final String deviceId;
|
||||
CuratedObject({
|
||||
required this.id,
|
||||
required this.object,
|
||||
required this.resizePath,
|
||||
required this.deviceAssetId,
|
||||
required this.deviceId,
|
||||
});
|
||||
|
||||
CuratedObject copyWith({
|
||||
String? id,
|
||||
String? object,
|
||||
String? resizePath,
|
||||
String? deviceAssetId,
|
||||
String? deviceId,
|
||||
}) {
|
||||
return CuratedObject(
|
||||
id: id ?? this.id,
|
||||
object: object ?? this.object,
|
||||
resizePath: resizePath ?? this.resizePath,
|
||||
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'id': id});
|
||||
result.addAll({'object': object});
|
||||
result.addAll({'resizePath': resizePath});
|
||||
result.addAll({'deviceAssetId': deviceAssetId});
|
||||
result.addAll({'deviceId': deviceId});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory CuratedObject.fromMap(Map<String, dynamic> map) {
|
||||
return CuratedObject(
|
||||
id: map['id'] ?? '',
|
||||
object: map['object'] ?? '',
|
||||
resizePath: map['resizePath'] ?? '',
|
||||
deviceAssetId: map['deviceAssetId'] ?? '',
|
||||
deviceId: map['deviceId'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CuratedObject.fromJson(String source) => CuratedObject.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CuratedObject(id: $id, object: $object, resizePath: $resizePath, deviceAssetId: $deviceAssetId, deviceId: $deviceId)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is CuratedObject &&
|
||||
other.id == id &&
|
||||
other.object == object &&
|
||||
other.resizePath == resizePath &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.deviceId == deviceId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ object.hashCode ^ resizePath.hashCode ^ deviceAssetId.hashCode ^ deviceId.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_page_state.model.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/search/services/search.service.dart';
|
||||
@@ -64,3 +65,14 @@ final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocati
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
final getCuratedObjectProvider = FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
|
||||
final SearchService _searchService = SearchService();
|
||||
|
||||
var curatedObject = await _searchService.getCuratedObjects();
|
||||
if (curatedObject != null) {
|
||||
return curatedObject;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
|
||||
@@ -52,4 +53,19 @@ class SearchService {
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CuratedObject>?> getCuratedObjects() async {
|
||||
try {
|
||||
var res = await _networkService.getRequest(url: "asset/allObjects");
|
||||
|
||||
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||
|
||||
List<CuratedObject> result = List.from(decodedData.map((a) => CuratedObject.fromMap(a)));
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] [CuratedObject] ${e.toString()}");
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
mobile/lib/modules/search/ui/thumbnail_with_info.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
|
||||
: super(key: key);
|
||||
|
||||
final String textInfo;
|
||||
final String imageUrl;
|
||||
final Function onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onTap();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black26,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: CachedNetworkImage(
|
||||
width: 250,
|
||||
height: 250,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 10,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Text(
|
||||
textInfo.capitalizeFirstLetter(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class SearchPage extends HookConsumerWidget {
|
||||
@@ -22,6 +24,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
var box = Hive.box(userInfoBox);
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
|
||||
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
|
||||
|
||||
useEffect(() {
|
||||
searchFocusNode = FocusNode();
|
||||
@@ -37,12 +40,12 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
_buildPlaces() {
|
||||
return curatedLocation.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (curatedLocations) {
|
||||
return curatedLocations.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -63,7 +66,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -82,6 +85,54 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_buildThings() {
|
||||
return curatedObjects.when(
|
||||
loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (objects) {
|
||||
return objects.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: curatedObjects.value?.length,
|
||||
itemBuilder: ((context, index) {
|
||||
CuratedObject curatedObjectInfo = objects[index];
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${curatedObjectInfo.deviceAssetId}&did=${curatedObjectInfo.deviceId}&isThumb=true';
|
||||
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: curatedObjectInfo.object,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 1,
|
||||
itemBuilder: ((context, index) {
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
|
||||
textInfo: 'No Object Info Available',
|
||||
onTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: SearchBar(
|
||||
searchFocusNode: searchFocusNode,
|
||||
@@ -104,6 +155,14 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
_buildPlaces(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
"Things",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
),
|
||||
_buildThings()
|
||||
],
|
||||
),
|
||||
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
||||
@@ -113,66 +172,3 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
|
||||
: super(key: key);
|
||||
|
||||
final String textInfo;
|
||||
final String imageUrl;
|
||||
final Function onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onTap();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black26,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: CachedNetworkImage(
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 10,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Text(
|
||||
textInfo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
@@ -107,7 +108,10 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (searchResultPageState.isLoading) {
|
||||
return const CircularProgressIndicator.adaptive();
|
||||
return Center(
|
||||
child: SpinKitDancingSquare(
|
||||
color: Theme.of(context).primaryColor,
|
||||
));
|
||||
}
|
||||
|
||||
if (searchResultPageState.isSuccess) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:immich_mobile/shared/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ class _$AppRouter extends RootStackRouter {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl));
|
||||
child: VideoViewerPage(
|
||||
key: args.key, videoUrl: args.videoUrl, asset: args.asset));
|
||||
},
|
||||
BackupControllerRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
@@ -163,24 +164,29 @@ class ImageViewerRouteArgs {
|
||||
/// generated route for
|
||||
/// [VideoViewerPage]
|
||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
VideoViewerRoute({Key? key, required String videoUrl})
|
||||
VideoViewerRoute(
|
||||
{Key? key, required String videoUrl, required ImmichAsset asset})
|
||||
: super(VideoViewerRoute.name,
|
||||
path: '/video-viewer-page',
|
||||
args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl));
|
||||
args: VideoViewerRouteArgs(
|
||||
key: key, videoUrl: videoUrl, asset: asset));
|
||||
|
||||
static const String name = 'VideoViewerRoute';
|
||||
}
|
||||
|
||||
class VideoViewerRouteArgs {
|
||||
const VideoViewerRouteArgs({this.key, required this.videoUrl});
|
||||
const VideoViewerRouteArgs(
|
||||
{this.key, required this.videoUrl, required this.asset});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String videoUrl;
|
||||
|
||||
final ImmichAsset asset;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}';
|
||||
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
if (route.name == 'SearchRoute') {
|
||||
// Refresh Location State
|
||||
ref.refresh(getCuratedLocationProvider);
|
||||
ref.refresh(getCuratedObjectProvider);
|
||||
}
|
||||
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
|
||||
@@ -10,6 +10,8 @@ class ImmichAsset {
|
||||
final String modifiedAt;
|
||||
final bool isFavorite;
|
||||
final String? duration;
|
||||
final String originalPath;
|
||||
final String resizePath;
|
||||
|
||||
ImmichAsset({
|
||||
required this.id,
|
||||
@@ -21,6 +23,8 @@ class ImmichAsset {
|
||||
required this.modifiedAt,
|
||||
required this.isFavorite,
|
||||
this.duration,
|
||||
required this.originalPath,
|
||||
required this.resizePath,
|
||||
});
|
||||
|
||||
ImmichAsset copyWith({
|
||||
@@ -33,6 +37,8 @@ class ImmichAsset {
|
||||
String? modifiedAt,
|
||||
bool? isFavorite,
|
||||
String? duration,
|
||||
String? originalPath,
|
||||
String? resizePath,
|
||||
}) {
|
||||
return ImmichAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -44,6 +50,8 @@ class ImmichAsset {
|
||||
modifiedAt: modifiedAt ?? this.modifiedAt,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
duration: duration ?? this.duration,
|
||||
originalPath: originalPath ?? this.originalPath,
|
||||
resizePath: resizePath ?? this.resizePath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +66,8 @@ class ImmichAsset {
|
||||
'modifiedAt': modifiedAt,
|
||||
'isFavorite': isFavorite,
|
||||
'duration': duration,
|
||||
'originalPath': originalPath,
|
||||
'resizePath': resizePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +82,8 @@ class ImmichAsset {
|
||||
modifiedAt: map['modifiedAt'] ?? '',
|
||||
isFavorite: map['isFavorite'] ?? false,
|
||||
duration: map['duration'],
|
||||
originalPath: map['originalPath'] ?? '',
|
||||
resizePath: map['resizePath'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +93,7 @@ class ImmichAsset {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration)';
|
||||
return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration, originalPath: $originalPath, resizePath: $resizePath)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -97,7 +109,9 @@ class ImmichAsset {
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.duration == duration;
|
||||
other.duration == duration &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,6 +124,8 @@ class ImmichAsset {
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
duration.hashCode;
|
||||
duration.hashCode ^
|
||||
originalPath.hashCode ^
|
||||
resizePath.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,24 +37,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
Ref? ref;
|
||||
final BackupService _backupService = BackupService();
|
||||
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||
final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast();
|
||||
final StreamController _onAssetBackupStreamCtrl =
|
||||
StreamController.broadcast();
|
||||
|
||||
void getBackupInfo() async {
|
||||
_updateServerInfo();
|
||||
|
||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common);
|
||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
|
||||
onlyAll: true, type: RequestType.common);
|
||||
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
|
||||
|
||||
if (list.isEmpty) {
|
||||
debugPrint("No Asset On Device");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length);
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
totalAssetCount: 0,
|
||||
assetOnDatabase: didBackupAsset.length);
|
||||
return;
|
||||
}
|
||||
|
||||
int totalAsset = list[0].assetCount;
|
||||
|
||||
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
||||
state = state.copyWith(
|
||||
totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
||||
}
|
||||
|
||||
void startBackupProcess() async {
|
||||
@@ -67,8 +72,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
await PhotoManager.clearFileCache();
|
||||
// await PhotoManager.presentLimited();
|
||||
// Gather assets info
|
||||
List<AssetPathEntity> list =
|
||||
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
|
||||
hasAll: true, onlyAll: true, type: RequestType.common);
|
||||
|
||||
// Get device assets info from database
|
||||
// Compare and find different assets that has not been backing up
|
||||
@@ -78,14 +83,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
if (list.isEmpty) {
|
||||
debugPrint("No Asset On Device - Abort Backup Process");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: backupAsset.length);
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
totalAssetCount: 0,
|
||||
assetOnDatabase: backupAsset.length);
|
||||
return;
|
||||
}
|
||||
|
||||
int totalAsset = list[0].assetCount;
|
||||
List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset);
|
||||
List<AssetEntity> currentAssets =
|
||||
await list[0].getAssetListRange(start: 0, end: totalAsset);
|
||||
|
||||
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
||||
state = state.copyWith(
|
||||
totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
||||
// Remove item that has already been backed up
|
||||
for (var backupAssetId in backupAsset) {
|
||||
currentAssets.removeWhere((e) => e.id == backupAssetId);
|
||||
@@ -97,9 +106,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
state = state.copyWith(backingUpAssetCount: currentAssets.length);
|
||||
|
||||
// Perform Packup
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancelToken());
|
||||
_backupService.backupAsset(currentAssets, state.cancelToken, _onAssetUploaded, _onUploadProgress);
|
||||
_backupService.backupAsset(currentAssets, state.cancelToken,
|
||||
_onAssetUploaded, _onUploadProgress);
|
||||
} else {
|
||||
PhotoManager.openSetting();
|
||||
}
|
||||
@@ -107,22 +117,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
void cancelBackup() {
|
||||
state.cancelToken.cancel('Cancel Backup');
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
state =
|
||||
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
|
||||
state = state.copyWith(
|
||||
backingUpAssetCount: state.backingUpAssetCount - 1,
|
||||
assetOnDatabase: state.assetOnDatabase + 1);
|
||||
|
||||
if (state.backingUpAssetCount == 0) {
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
||||
}
|
||||
|
||||
_updateServerInfo();
|
||||
}
|
||||
|
||||
void _onUploadProgress(int sent, int total) {
|
||||
state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||
state = state.copyWith(
|
||||
progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||
}
|
||||
|
||||
void _updateServerInfo() async {
|
||||
@@ -156,7 +170,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||
if ((authState.deviceInfo.deviceId == authState.deviceId) &&
|
||||
authState.deviceInfo.isAutoBackup) {
|
||||
// check if backup is alreayd in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||
@@ -173,6 +188,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
final backupProvider =
|
||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(ref: ref);
|
||||
});
|
||||
|
||||
@@ -106,6 +106,20 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopListenToEvent(String eventName) {
|
||||
debugPrint("[Websocket] Stop listening to event $eventName");
|
||||
state.socket?.off(eventName);
|
||||
}
|
||||
|
||||
listenUploadEvent() {
|
||||
debugPrint("[Websocket] Start listening to event on_upload_success");
|
||||
state.socket?.on('on_upload_success', (data) {
|
||||
var jsonString = jsonDecode(data.toString());
|
||||
ImmichAsset newAsset = ImmichAsset.fromMap(jsonString);
|
||||
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
|
||||
|
||||
@@ -73,7 +73,7 @@ class BackupService {
|
||||
});
|
||||
|
||||
// Build thumbnail multipart data
|
||||
var thumbnailData = await entity.thumbDataWithSize(1280, 720);
|
||||
var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280));
|
||||
if (thumbnailData != null) {
|
||||
thumbnailUploadData = MultipartFile.fromBytes(
|
||||
List.from(thumbnailData),
|
||||
@@ -112,7 +112,10 @@ class BackupService {
|
||||
}
|
||||
} on DioError catch (e) {
|
||||
debugPrint("DioError backupAsset: ${e.response}");
|
||||
break;
|
||||
if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
continue;
|
||||
|
||||