Compare commits

...

42 Commits

Author SHA1 Message Date
Alex Tran
df0a059a02 Up patch version 2022-11-21 20:26:03 -06:00
Alex
cc697486fc fix(server): Deleted shared users cause problem with album retrival and creation (#1002)
* fix(server): Deleted shared users cause problem with album retrival and creation

* Remove dead code
2022-11-21 20:24:56 -06:00
Alex
2227a6f5f3 Added custom buildscript for XCodeCloud 2022-11-21 13:54:30 -06:00
Alex
a9320f06e8 Added v1.36 release note to website 2022-11-21 12:53:25 -06:00
Alex
39b7ab66d4 chore(mobile): clean up linter problems (#1000) 2022-11-21 06:13:14 -06:00
Alex Tran
bc9ee1d611 Added hotfix release note 2022-11-21 05:41:44 -06:00
Alex
56ce747ffc fix(mobile): freeze on splash screen due to accessing bad state (#998) 2022-11-21 05:29:43 -06:00
Alex
a2f3b2199a fix(server): Admin user not created (#996) 2022-11-20 23:25:03 -06:00
Alex Tran
88b8d34aa6 Update .env.example file 2022-11-20 16:44:33 -06:00
Alex Tran
21fd08e0fb Update Readme 2022-11-20 14:42:09 -06:00
Alex Tran
37a4f4a39f Update redirect picture in documentation 2022-11-20 14:18:53 -06:00
Alex Tran
9d2c30298e Added changelog for mobile 2022-11-20 14:11:33 -06:00
Alex Tran
6f5d60fb62 Up version for release 2022-11-20 13:13:27 -06:00
Alex
41ffa0c015 fix(server): Server freezes when getting statistic (#994)
* fix(server): Server freezes when getting statistic
* remove dead code
2022-11-20 13:09:31 -06:00
Alex
b3e51cc849 feat(mobile) Add OAuth Login On Mobile (#990)
* Added return type for oauth/callback

* Remove console.log

* Redirect app

* Wording

* Added loading state change

* Added OAuth login on mobile

* Return correct status for  correct redirection

* Auto discovery OAuth Login
2022-11-20 11:43:10 -06:00
Alex Tran
e01e4e6530 Fixed motion play icon in light mode mobile 2022-11-19 15:23:49 -06:00
Alex Tran
6ed072f67b Added migration needed for OIDC 2022-11-18 23:22:27 -06:00
Alex
8bc64be77b feat: support iOS LivePhoto backup (#950) 2022-11-18 23:12:54 -06:00
Brandon Rothweiler
83e2cabbcc Update contribution-guidelines.md (#985) 2022-11-16 23:30:58 -06:00
Alex
7de7619fd1 Update contribution-guidelines.md 2022-11-16 23:15:26 -06:00
bo0tzz
afae5fd972 web(feat): Add support for actions when clicking notifications (#966)
* feat(web): Add button to close notification popups

* feat(web): Add support for actions on notification click

* feat(web): Open CLI docs when clicking asset upload count message

* test(web): Add action field to notification-card tests

* chore(web): Formatting

* feat(web): Allow HTML in notification message

* feat(web): Do not use link element in file upload count notification

* feat(web): Prevent notification discard button from triggering action

* feat(web): Add noop action support for notifications

* chore(web): Remove unused function argument
2022-11-16 23:11:15 -06:00
bo0tzz
70cd313082 Add navbar button to copy image (#961)
* Add navbar button to copy image

* Use global event for copy image

* merge upstream

* Fixed missing required props

* feat(web): Show notification after copying image to clipboard

* chore(web): Fix typescript error

* chore(web): Formatting

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-11-16 15:04:37 -06:00
Alex
e799f35dd2 chore(server) refactor serveFile and downloadFile endpoint (#978) 2022-11-16 00:11:16 -06:00
Kiel Hurley
1db255fd3e Disallow all robots (#977)
Blank `Disallow` disallows nothing
2022-11-15 21:36:04 -06:00
Jason Rasmussen
909e4820d6 chore(web,mobile): update github repo url (#974) 2022-11-15 20:30:44 -06:00
be bright
4727671c79 Update Korean translation with the latest version. (#971) 2022-11-15 09:52:24 -06:00
Jason Rasmussen
f2f255e6e6 feat(server): multi archive downloads (#956) 2022-11-15 09:51:56 -06:00
Jason Rasmussen
b5d75e2016 feat(server,web): system config for admin (#959)
* feat: add admin config module for user configured config, uses it for ffmpeg

* feat: add api endpoint to retrieve admin config settings and values

* feat: add settings panel to admin page on web (wip)

* feat: add api endpoint to update the admin config

* chore: re-generate openapi spec after rebase

* refactor: move from admin config to system config naming

* chore: move away from UseGuards to new @Authenticated decorator

* style: dark mode styling for lists and fix conflicting colors

* wip: 2 column design, no edit button

* refactor: system config

* chore: generate open api

* chore: rm broken test

* chore: cleanup types

* refactor: config module names

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
Co-authored-by: Zack Pollard <zack.pollard@moonpig.com>
2022-11-14 22:39:32 -06:00
Jason Rasmussen
d3c35ec9c5 feat(server,web): OIDC Implementation (#884)
* chore: merge

* feat: nullable password

* feat: server debugger

* chore: regenerate api

* feat: auto-register flag

* refactor: oauth endpoints

* chore: regenerate api

* fix: default scope configuration

* refactor: pass in redirect uri from client

* chore: docs

* fix: bugs

* refactor: auth services and user repository

* fix: select password

* fix: tests

* fix: get signing algorithm from discovery document

* refactor: cookie constants

* feat: oauth logout

* test: auth services

* fix: query param check

* fix: regenerate open-api
2022-11-14 20:24:25 -06:00
Devin Buhl
d476656789 feat(ci): Push images to GitHub Container Registry (#964)
* feat(ci): Push images to GitHub Container Registry

* fix up tag

* fix typo

* use github.repository_owner
2022-11-13 08:32:50 -06:00
Fynn Petersen-Frey
8d0ff974e1 refactor(mobile): tidy-up dependencies, remove unused, replace rarely used ones (#948) 2022-11-11 11:52:02 -06:00
Alex
33ded2a174 Update README.md 2022-11-11 09:41:39 -06:00
Alex
277af33ab0 Update automatic-backup.md
Fixed incorrect sentence idea
2022-11-10 22:44:04 -06:00
Jason Rasmussen
2e4c005ad9 refactor: multistage builds (#955) 2022-11-10 22:22:17 -06:00
Alex
739bed737e Added database migration info to docs 2022-11-10 09:30:32 -06:00
bo0tzz
a1a7e6ac06 Small docs site tweaks (#954)
* Add yarn.lock to .gitignore

* Tweak announcementBar message

* Add demo link to docs homepage

* chore(docs): logo-meaning page cleanup

* chore(docs): support page cleanup

* chore(docs): tech-stack page cleanup

* chore(docs): requirements page cleanup

* Update main README warning to match docs site

* Add clearer documentation link to main README

* chore(docs): one-step-install page cleanup

* chore(docs): Security: remove example JWT_SECRET value

* chore(docs): recommended-install page cleanup

* chore(docs): portainer-install page cleanup

* chore(docs): unraid-install page cleanup
2022-11-10 08:20:23 -06:00
Alex Tran
c3348bd068 Fixed Dockerfile not working in dev 2022-11-09 23:34:35 -06:00
Jason Rasmussen
cc61729f01 build(server): use github-action cache (#949)
* build(server): prune dependencies in docker builder

* fix: e2e tests

* refactor: dockerfile step order

* fix: vips build dependency

* feat: use caching
2022-11-09 19:53:21 -06:00
Christian Paul
b457bfbd4e typo(android-feature-note): Reserve geocoding -> Reverse geocoding (#946)
* Typo: Reserve geocoding -> Reverse geocoding

https://en.wikipedia.org/wiki/Reverse_geocoding

* Update mobile/android/fastlane/metadata/android/en-US/full_description.txt
2022-11-09 10:17:43 -06:00
Jason Rasmussen
1877834fd1 fix(web): broken unit tests (#947) 2022-11-09 10:32:12 -05:00
Alex Tran
afdfd1863f Adjusting dark mode color on the web admin panel 2022-11-09 05:42:06 -06:00
Ian
f6aba0f9ec feat(deployment) Allow overriding service host and ports with env variables (#930)
* Add proxy changes

* Add web changes

* Add microservices changes

* Add examples

* Add header comment to nginx config

* Use URLs instead of host and port
2022-11-09 05:11:32 -06:00
262 changed files with 6779 additions and 1810 deletions

View File

@@ -26,6 +26,12 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.2.0
with:
@@ -33,8 +39,11 @@ jobs:
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
altran1502/immich-server:latest
ghcr.io/${{ github.repository_owner }}/immich-server:latest
build_and_push_machine_learning_latest:
runs-on: ubuntu-latest
@@ -54,6 +63,12 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.2.0
with:
@@ -61,8 +76,11 @@ jobs:
file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
altran1502/immich-machine-learning:latest
ghcr.io/${{ github.repository_owner }}/immich-machine-learning:latest
build_and_push_web_latest:
runs-on: ubuntu-latest
@@ -81,6 +99,12 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.2.0
with:
@@ -91,6 +115,7 @@ jobs:
push: true
tags: |
altran1502/immich-web:latest
ghcr.io/${{ github.repository_owner }}/immich-web:latest
build_and_push_nginx_latest:
runs-on: ubuntu-latest
@@ -109,6 +134,12 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.2.0
with:
@@ -118,3 +149,4 @@ jobs:
push: true
tags: |
altran1502/immich-proxy:latest
ghcr.io/${{ github.repository_owner }}/immich-proxy:latest

View File

@@ -27,6 +27,13 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Immich Mono Repo
uses: docker/build-push-action@v3.2.0
with:
@@ -34,9 +41,13 @@ jobs:
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
altran1502/immich-server:staging
altran1502/immich-server:${{ github.event.pull_request.number }}
ghcr.io/${{ github.repository_owner }}/immich-server:staging
ghcr.io/${{ github.repository_owner }}/immich-server:${{ github.event.pull_request.number }}
build_and_push_machine_learning_staging:
runs-on: ubuntu-latest
@@ -57,6 +68,13 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.2.0
with:
@@ -64,9 +82,13 @@ jobs:
file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
altran1502/immich-machine-learning:staging
altran1502/immich-machine-learning:${{ github.event.pull_request.number }}
ghcr.io/${{ github.repository_owner }}/immich-machine-learning:staging
ghcr.io/${{ github.repository_owner }}/immich-machine-learning:${{ github.event.pull_request.number }}
build_and_push_web_staging:
runs-on: ubuntu-latest
@@ -86,6 +108,13 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.2.0
with:
@@ -97,6 +126,8 @@ jobs:
tags: |
altran1502/immich-web:staging
altran1502/immich-web:${{ github.event.pull_request.number }}
ghcr.io/${{ github.repository_owner }}/immich-web:staging
ghcr.io/${{ github.repository_owner }}/immich-web:${{ github.event.pull_request.number }}
build_and_push_nginx_staging:
runs-on: ubuntu-latest
@@ -116,6 +147,13 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.repository == 'immich-app/immich' }}
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Proxy
uses: docker/build-push-action@v3.2.0
with:
@@ -126,3 +164,5 @@ jobs:
tags: |
altran1502/immich-proxy:staging
altran1502/immich-proxy:${{ github.event.pull_request.number }}
ghcr.io/${{ github.repository_owner }}/immich-proxy:staging
ghcr.io/${{ github.repository_owner }}/immich-proxy:${{ github.event.pull_request.number }}

View File

@@ -12,12 +12,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
ref: 'main'
fetch-depth: 0
- name: "Get Previous tag"
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: 'WyriHaximus/github-action-get-previous-tag@v1'
with:
fallback: latest
@@ -34,6 +34,13 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push immich-server release
uses: docker/build-push-action@v3.2.0
with:
@@ -41,9 +48,13 @@ jobs:
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
altran1502/immich-server:release
ghcr.io/${{ github.repository_owner }}/immich-server:${{ steps.previoustag.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/immich-server:release
build_and_push_machine_learning_release:
runs-on: ubuntu-latest
@@ -52,9 +63,9 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: "Get Previous tag"
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: 'WyriHaximus/github-action-get-previous-tag@v1'
with:
fallback: latest
- name: Set up QEMU
@@ -67,6 +78,12 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Machine Learning
uses: docker/build-push-action@v3.2.0
with:
@@ -74,9 +91,13 @@ jobs:
file: ./machine-learning/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
altran1502/immich-machine-learning:${{ steps.previoustag.outputs.tag }}
altran1502/immich-machine-learning:release
ghcr.io/${{ github.repository_owner }}/immich-machine-learning:${{ steps.previoustag.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/immich-machine-learning:release
build_and_push_web_release:
runs-on: ubuntu-latest
@@ -84,12 +105,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
ref: 'main'
fetch-depth: 0
- name: "Get Previous tag"
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: 'WyriHaximus/github-action-get-previous-tag@v1'
with:
fallback: latest
@@ -106,6 +127,13 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push immich-web release
uses: docker/build-push-action@v3.2.0
with:
@@ -117,6 +145,8 @@ jobs:
tags: |
altran1502/immich-web:${{ steps.previoustag.outputs.tag }}
altran1502/immich-web:release
ghcr.io/${{ github.repository_owner }}/immich-web:${{ steps.previoustag.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/immich-web:release
build_and_push_nginx_release:
runs-on: ubuntu-latest
@@ -124,12 +154,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
ref: 'main'
fetch-depth: 0
- name: "Get Previous tag"
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
uses: 'WyriHaximus/github-action-get-previous-tag@v1'
with:
fallback: latest
@@ -146,6 +176,13 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push immich-proxy release
uses: docker/build-push-action@v3.2.0
with:
@@ -156,3 +193,5 @@ jobs:
tags: |
altran1502/immich-proxy:release
altran1502/immich-proxy:${{ steps.previoustag.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/immich-proxy:${{ steps.previoustag.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/immich-proxy:release

View File

@@ -22,7 +22,7 @@
- ⚠️ The project is under **very active** development.
- ⚠️ Expect bugs and breaking changes.
- ⚠️ **Do not use as a single source to store of your photos and videos!**
- ⚠️ **Do not use the app as the only way to store your photos and videos!**
## Content
@@ -35,6 +35,10 @@
- [Support The Project](#support-the-project)
- [Known Issues](#known-issues)
## Documentation
You can find the main documentation, including installation guides, at https://immich.app/.
## Demo
You can access the web demo at https://demo.immich.app
@@ -68,7 +72,9 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Search by metadata, objects and image tags | Yes | No |
| Administrative functions (user management) | N/A | Yes |
| Background backup | Android | N/A |
| Virtual scroll | N/A | Yes |
| Virtual scroll | Yes | Yes |
| OAuth Support | Yes | Yes |
| LivePhotos Backup and Playback (iOS only) | Yes | Yes |
# Support the project

View File

@@ -23,7 +23,9 @@ REDIS_HOSTNAME=immich_redis
# REDIS_SOCKET=
###################################################################################
# Upload File Config
# Upload File Location
#
# This is the location where uploaded files are stored.
###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
@@ -36,19 +38,17 @@ LOG_LEVEL=simple
###################################################################################
# JWT SECRET
###################################################################################
#
# This JWT_SECRET is used to sign the authentication keys for user login
# You should set it to a long randomly generated value
# You can use this command to generate one: openssl rand -base64 128
###################################################################################
JWT_SECRET=
###################################################################################
# Reverse Geocoding
####################################################################################
# DISABLE_REVERSE_GEOCODING=false
#
# Reverse geocoding is done locally which has a small impact on memory usage
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
# This ranges from 0-3 with 3 being the most precise
@@ -56,14 +56,44 @@ JWT_SECRET=
# 2 - Cities > 1000 population: ~150MB RAM
# 1 - Cities > 5000 population: ~80MB RAM
# 0 - Cities > 15000 population: ~40MB RAM
####################################################################################
# DISABLE_REVERSE_GEOCODING=false
# REVERSE_GEOCODING_PRECISION=3
####################################################################################
# WEB - Optional
#
# Custom message on the login page, should be written in HTML form.
# For example:
# PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
####################################################################################
# Custom message on the login page, should be written in HTML form.
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
PUBLIC_LOGIN_PAGE_MESSAGE=
####################################################################################
# Alternative Service Addresses - Optional
#
# This is an advanced feature for users who may be running their immich services on different hosts.
# It will not change which address or port that services bind to within their containers, but it will change where other services look for their peers.
# Note: immich-microservices is bound to 3002, but no references are made
####################################################################################
# IMMICH_WEB_URL=http://immich-web:3000
# IMMICH_SERVER_URL=http://immich-server:3001
# IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
####################################################################################
# OAuth Setting - Optional
#
# These setting will enable OAuth login for your instance of Immich
# Folow the instructions in the page https://immich.app/docs/usage/oauth to set up your OAuth provider
####################################################################################
# OAUTH_ENABLED=false
# OAUTH_ISSUER_URL=
# OAUTH_CLIENT_ID=
# OAUTH_CLIENT_SECRET=
# OAUTH_BUTTON_TEXT=Login with OAuth
# OAUTH_AUTO_REGISTER=true
# OAUTH_SCOPE="openid profile email"

View File

@@ -68,6 +68,9 @@ services:
command: npm run dev --host
env_file:
- .env
environment:
# Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
ports:
- 3000:3000
- 24678:24678
@@ -100,6 +103,10 @@ services:
immich-proxy:
container_name: immich_proxy
image: immich-proxy-dev:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile

View File

@@ -47,6 +47,9 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
environment:
# Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
restart: always
redis:
@@ -71,6 +74,10 @@ services:
immich-proxy:
container_name: immich_proxy
image: altran1502/immich-proxy:staging
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
ports:
- 2283:8080
logging:

View File

@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
services:
immich-server-test:
@@ -9,7 +9,7 @@ services:
target: builder
command: npm run test:e2e
expose:
- "3000"
- '3000'
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules

View File

@@ -47,6 +47,9 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
environment:
# Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
restart: always
redis:
@@ -71,6 +74,10 @@ services:
immich-proxy:
container_name: immich_proxy
image: altran1502/immich-proxy:release
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
ports:
- 2283:8080
logging:

1
docs/.gitignore vendored
View File

@@ -18,3 +18,4 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock

View File

@@ -1,12 +0,0 @@
---
slug: first-blog-post
title: First Blog Post
authors:
name: Gao Wei
title: Docusaurus Core Team
url: https://github.com/wgao19
image_url: https://github.com/wgao19.png
tags: [hola, docusaurus]
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

View File

@@ -1,44 +0,0 @@
---
slug: long-blog-post
title: Long Blog Post
authors: endi
tags: [hello, docusaurus]
---
This is the summary of a very long blog post,
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
<!--truncate-->
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

View File

@@ -1,20 +0,0 @@
---
slug: mdx-blog-post
title: MDX Blog Post
authors: [slorber]
tags: [docusaurus]
---
Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
:::tip
Use the power of React to create interactive blog posts.
```js
<button onClick={() => alert('button clicked!')}>Click me!</button>
```
<button onClick={() => alert('button clicked!')}>Click me!</button>
:::

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -1,25 +0,0 @@
---
slug: welcome
title: Welcome
authors: [slorber, yangshun]
tags: [facebook, hello, docusaurus]
---
[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
Simply add Markdown files (or folders) to the `blog` directory.
Regular blog authors can be added to `authors.yml`.
The blog post date can be extracted from filenames, such as:
- `2019-05-30-welcome.md`
- `2019-05-30-welcome/index.md`
A blog post folder can be convenient to co-locate blog post images:
![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg)
The blog supports tags as well!
**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.

View File

@@ -1,17 +1,5 @@
endi:
name: Endilie Yacop Sucipto
title: Maintainer of Docusaurus
url: https://github.com/endiliey
image_url: https://github.com/endiliey.png
yangshun:
name: Yangshun Tay
title: Front End Engineer @ Facebook
url: https://github.com/yangshun
image_url: https://github.com/yangshun.png
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
alextran:
name: Alex Tran
title: Maintainer of Immich
url: https://github.com/alextran1502
image_url: https://github.com/alextran1502.png

View File

@@ -0,0 +1,114 @@
---
slug: release-1-36
title: Release v1.36.0
authors: [alextran]
tags: [release]
date: 2022-11-10
---
Hello everyone, it is my pleasure to deliver the new release of Immich to you. The team has been working hard to bring you the new features and improvements. This release includes some big features that the community has been asking since the beginning of Immich. We hope you will enjoy it.
Some notable features are:
- [OAuth integration](#livephoto-ios-support-)
- [LivePhoto support on iOS](#oauth-integration-)
- User config system
<!--truncate-->
## LivePhoto iOS Support 🎉
LivePhoto on iOS is now supported in Immich.
The motion part will now be uploaded and can be played on the mobile app and the web.
:::caution
- The server and the app has to be on version **1.36.x** for the application to work correctly.
- Previous uploaded photos will not be updated automatically, you will have to remove and reupload them if you want to keep the LivePhoto functionality.
:::
<img
src="https://media.giphy.com/media/fTrGceZd7t1ewi8ESc/giphy.gif"
width="100%"
style={{
borderRadius: "10px",
boxShadow:
"rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
}}
title="LivePhoto playback on the web"
/>
## OAuth Integration 🎉
I want to borrow this chance to express my gratitude to [@EnricoBilla](https://github.com/EnricoBilla), who has been the trailblazer for this feature since the beginning days of Immich. His PR has sparked ideas, suggestions, and discussion among the team member on how to integrate this feature successfully into the app. Thank you so much for your work and your time.
OAuth is now integrated into the system. Please follow the guide [here](https://immich.app/docs/usage/oauth) to set up your OAuth integration
After setting up the correct environment variables in the `.env` file, as shown below
| Key | Type | Default | Description |
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client |
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID |
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret |
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
```bash title="Authentik Example"
OAUTH_ENABLED=true
OAUTH_ISSUER_URL=http://10.1.15.216:9000/application/o/immich-test/
OAUTH_CLIENT_ID=30596v8f78a4b6a97d5985c3076b6b4c4d12ddc33
OAUTH_CLIENT_SECRET=50f1eafdec353b95b1c638db390db4ab67ef035a51212dbec2f56175e2eb272b5d572c099176e6fe116ecf47ffdd544bgdb9e2edc588307ee0339d25eeccd88
OAUTH_BUTTON_TEXT=Login with Authentik
```
The web will have the option to sign in with OAuth.
<img
src="https://user-images.githubusercontent.com/27055614/202923726-f43fa148-47f5-4182-8f29-b0b87e4586fa.png"
width="50%"
title="Web Sign in with OAuth"
style={{
borderRadius: "10px",
boxShadow:
"rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
}}
/>
The mobile app will check if the server has OAuth enabled before displaying the OAuth
sign-in button.
<img
src="https://media.giphy.com/media/3iy3SaNkVYtlkEiw06/giphy.gif"
title="Mobile sign in with OAuth"
style={{
borderRadius: "10px",
boxShadow:
"rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
}}
/>
## Support
<img
src="https://media.giphy.com/media/LStqgGESXW8XnuCv5y/giphy.gif"
width="300"
style={{
borderRadius: "10px",
boxShadow:
"rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
}}
title="Support the project"
/>
If you find the project helpful and it helps you in some ways, you can support the project [one time](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or [monthly](https://github.com/sponsors/alextran1502) from GitHub Sponsor
It is a great way to let me know that you want me to continue developing and working on this project for years to come.
## Details
For more details, please check out the [release note](https://github.com/immich-app/immich/releases/tag/v1.36.0_55-dev)

View File

@@ -20,14 +20,23 @@ This environment includes the following services:
All the services are packaged to run as with single Docker Compose command.
After cloning the project, from the root directory run
### Instructions
1. Clone the project repo.
2. Run `cp docker/.env.example docker/.env`.
3. Edit `docker/.env` to provide values for the required variables `UPLOAD_LOCATION` and `JWT_SECRET`.
4. From the root directory, run:
```bash title="Start development server"
make dev # required Makefile installed on the system.
```
5. Access the dev instance in your browser at http://localhost:2283, or connect via the mobile app.
All the services will be started with hot-reloading enabled for a quick feedback loop.
You can access the web from `http://your-machine-ip:2283` or `http://localhost:2283` and access the server from the mobile app at `http://your-machine-ip:2283/api`
### Mobile app
The mobile app `(/mobile)` will required Flutter toolchain to be installed on your system.
@@ -80,3 +89,15 @@ OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generato
npm run api:generate # Run from the `server` directory
```
You can find the generated client SDK in the `web/src/api` for Typescript SDK and `mobile/openapi` for Dart SDK.
## Database migrations
After making any changes in the `server/libs/database/src/entities`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration.
1. Attached to the server container shell.
2. Run
```bash
npm run typeorm -- migration:generate ./libs/database/src/<migration-name> -d libs/database/src/config/database.config.ts
```
3. Check if the migration file makes sense.
4. Move the migration file to folder `server/libs/database/src/migrations` in your code editor.

View File

@@ -22,7 +22,7 @@ The script will perform the following actions:
The web application will be available at `http://<machine-ip-address>:2283`, and the server URL for the mobile app will be `http://<machine-ip-address>:2283/api`
The directory which is used to store the backup file is `./immich-app/immich-data` relative to the current directory.
The directory which is used to store the library files is `./immich-data` relative to the current directory.
:::tip
For more information on how to use the application, please refer to the [Post Installation](/docs/usage/post-installation) guide.

View File

@@ -9,7 +9,7 @@ Install Immich using Portainer's Stack feature.
1. Go to "**Stacks**" in the left sidebar.
2. Click on "**Add stack**".
3. Give the stack a name (i.e. Immich), and select "**Web Editor**" as the build method.
4. Copy the content of the `docker-compose.yml` file from the [GitHub repository](https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml)
4. Copy the content of the `docker-compose.yml` file from the [GitHub repository](https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml).
5. Replace `.env` with `stack.env` for all containers that need to use environment variables in the web editor.
<img
@@ -28,7 +28,7 @@ Install Immich using Portainer's Stack feature.
alt="Dot Env Example"
/>
9. Copy the content of the `.env.example` file from the [GitHub repository](https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example) and paste to the editor.
9. Copy the content of the `.env.example` file from the [GitHub repository](https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example) and paste into the editor.
10. Switch back to "**Simple Mode**".
<img
@@ -39,8 +39,8 @@ Install Immich using Portainer's Stack feature.
/>
* Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use the command below to generate a secured key
* Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
* Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key:
```bash title="Generate secure JWT_SECRET key"
openssl rand -base64 128

View File

@@ -69,9 +69,7 @@ LOG_LEVEL=simple
# This JWT_SECRET is used to sign the authentication keys for user login
# You should set it to a long randomly generated value
# You can use this command to generate one: openssl rand -base64 128
JWT_SECRET=kWPdavjCECB0yoXgUHA/vpwpIKdCi/4ODVLIOe9WIi6AQlFfjWEuIVhWT3DtJE+T
CTckJnpwGgSK5AoqD+A8DZKsHCRdfVnlQIVqqmyR8isZTcxL5DWYQUSDRzyOO5OA
ZRUTE63FxiYhrRoe/y1yr5mV1osGy6mm6NZW8T2Tjwc=
JWT_SECRET=
###################################################################################
# Reverse Geocoding
@@ -102,8 +100,8 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
</details>
* Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use the command below to generate a secure key
* Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
* Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key:
```bash title="Command to generate secure JWT_SECRET key"
openssl rand -base64 128

View File

@@ -12,14 +12,14 @@ Hardware and software requirements for Immich
- [Docker Compose](https://docs.docker.com/compose/install/)
:::info Podman
You can also use Podman to run the application. However, additional configurations might be required on your end.
You can also use Podman to run the application. However, additional configuration might be required on your end.
:::
## Hardware
- **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc). Windows works too, with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/)
- **Ram**: At least 2GB, preferred 4GB.
- **Core**: At least 2 cores, preferred 4 cores.
- **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS, etc). Windows works too, with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/)
- **RAM**: At least 2GB, preferred 4GB.
- **CPU**: At least 2 cores, preferred 4 cores.
## Installation methods

View File

@@ -7,7 +7,7 @@ sidebar_position: 5
Install Immich on Unraid.
:::info Community contribution
Please follow this community contributed [article](https://mfaz.dev/posts/immich-unraid/) to install Immich on Unraid.
Please follow [this community contributed article](https://mfaz.dev/posts/immich-unraid/) to install Immich on Unraid.
:::
:::tip

View File

@@ -10,7 +10,7 @@ I really like the Japanese culture, especially the books, history, and food. The
![Oda_emblem](https://user-images.githubusercontent.com/27055614/182044504-a5ed33a8-5640-42de-b359-18fdbee9fb90.svg)
One of my favorite books is [Taikō](https://www.goodreads.com/book/show/336228.Taiko), it is the story about a prominent figure in the history of Japan, [Toyotomy Hideyoshi](https://www.britannica.com/biography/Toyotomi-Hideyoshi). He came from nothing, and through his resilience and wonderful mind, he has become one of the most powerful rulers in Japan's history. I enjoy his personality and the way he moved through life.
One of my favorite books is [Taikō](https://www.goodreads.com/book/show/336228.Taiko), it is a story about a prominent figure in the history of Japan, [Toyotomy Hideyoshi](https://www.britannica.com/biography/Toyotomi-Hideyoshi). He came from nothing, and through his resilience and wonderful mind, he has become one of the most powerful rulers in Japan's history. I enjoy his personality and the way he moved through life.
The color is an adaptation of **_App-Which-Must-Not-Be-Named_**'s color scheme, with an extra color (pink) to complete the flower's fifth petal. The petal layers are the same color scheme as the main layer rotating back and forth to "bring the flower to life."

View File

@@ -6,7 +6,7 @@ sidebar_position: 3
I've committed to this project, and I will not stop. I will keep updating the docs, adding new features, and fixing bugs. But I can't do it alone, so I need your help to give me additional motivation to keep going.
As our hosts in the [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) said, this is a massive undertaking; what the team and I are doing. I would love to someday be able to do this full-time, and I am asking for your help to make that happen.
As our hosts in the [selfhosted.show - In the episode 'The-organization-which-must-not-be-named is a Hostile Actor'](https://selfhosted.show/79?t=1418) said, this is a massive undertaking that the team and I are doing. I would love to someday be able to do this full-time, and I am asking for your help to make that happen.
If you feel like this is the right cause and the app is something you see yourself using for a long time, please consider supporting the project with the options below.

View File

@@ -4,7 +4,7 @@ sidebar_position: 4
# Technology stack
The app is built with the following technologies
The app is built with the following technologies:
## Frontend
* [Flutter](https://flutter.dev/) for the mobile app

View File

@@ -31,5 +31,5 @@ A native Android notification shows up when the background upload is in progress
:::note
* The app must be in the background for the backup worker to start running.
* It is a well-known problem that some Android models are very strict with battery optimization settings, which can cause a problem with the background worker. Please visit [Don't kill my app](https://dontkillmyapp.com/) for a guide on disabling this setting on your phone.
* If you reopen the app and the first page you see is the backup page, the counts will reflect the background uploaded result. You have to navigate out of the page and come back to see the updated counts.
* If you reopen the app and the first page you see is the backup page, the counts will not reflect the background uploaded result. You have to navigate out of the page and come back to see the updated counts.
:::

View File

@@ -8,17 +8,19 @@ You can use the CLI to upload an existing gallery to the Immich server
[Immich CLI Repository](https://github.com/immich-app/CLI)
## Requirements
* Node.js 16 or above
* Npm
- Node.js 16 or above
- Npm
## Installation
```bash
npm i -g immich
```
## Quick Start
Specify user's credentials, Immich's server address and port, and the directory you would like to upload videos/photos from.
```bash
@@ -58,10 +60,9 @@ immich upload --email testuser@email.com --password password --server http://192
### Run from source
```bash title="Clone Repository"
git clone https://github.com/alextran1502/immich-cli
git clone https://github.com/immich-app/CLI
```
```bash title="Install dependencies"
npm install
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

76
docs/docs/usage/oauth.md Normal file
View File

@@ -0,0 +1,76 @@
---
sidebar_position: 5
---
# OAuth Authentication
This page contains details about using OAuth 2 in Immich.
## Overview
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
- [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
## Prerequisites
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
1. Create a new (Client) Application
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
2. The **Client type** should be `Confidential`
3. The **Application** type should be `Web`
4. The **Grant** type should be `Authorization Code`
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
* All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
* Mobile app redirect URL `app.immich:/`
:::caution
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
**Authentik example**
<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
:::
## Enable OAuth
Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
| Key | Type | Default | Description |
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) |
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step) |
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
:::info
The Issuer URL should look something like the following, and return a valid json document.
- `https://accounts.google.com/.well-known/openid-configuration`
- `http://localhost:9000/application/o/immich/.well-known/openid-configuration`
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
:::
Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
```
OAUTH_ENABLED=true
OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
OAUTH_BUTTON_TEXT=Login with Authentik
```
[oidc]: https://openid.net/connect/

View File

@@ -58,7 +58,7 @@ const config = {
({
announcementBar: {
id: "site_announcement_immich",
content: `⚠️ The project is under <strong>very active</strong> development. Expect bugs and changes. Do not use as a <strong>single source</strong> to store of your photos and videos!`,
content: `⚠️ The project is under <strong>very active</strong> development. Expect bugs and changes. Do not use it as <strong>the only way</strong> to store your photos and videos!`,
backgroundColor: "#593f00",
textColor: "#ffefc9",
isCloseable: false,
@@ -80,7 +80,7 @@ const config = {
position: "right",
label: "Documentation",
},
// { to: "/blog", label: "Blog", position: "right" },
{ to: "/blog", label: "Blog", position: "right" },
{
href: "https://github.com/immich-app/immich",
label: "GitHub",

View File

@@ -54,3 +54,13 @@
.introButton:hover {
color: #000000;
}
.demoButton {
background-color: aquamarine;
color: #000000;
border-radius: 50px;
}
.demoButton:hover {
color: #000000;
}

View File

@@ -40,6 +40,15 @@ function HomepageHeader() {
Installation
</Link>
</div>
<div className={styles.buttons}>
<Link
className={clsx("button button--lg", styles.demoButton)}
to="https://demo.immich.app/"
>
Demo
</Link>
</div>
</div>
<img src="/img/immich-screenshots.webp" alt="logo" />

View File

@@ -1,43 +1,42 @@
# Build stage
FROM node:16-bullseye-slim as builder
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
COPY package.json package-lock.json ./
RUN npm ci
RUN npm rebuild @tensorflow/tfjs-node --build-from-source
COPY . .
FROM builder as prod
RUN npm run build
RUN npm prune --omit=dev
# Prod stage
FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
COPY entrypoint.sh ./
RUN mkdir -p /usr/src/app/dist \
&& mkdir -p /usr/src/app/node_modules \
&& apt-get update \
RUN apt-get update \
&& apt-get install -y ffmpeg \
&& rm -rf /var/cache/apt/lists
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
RUN npm prune --production
COPY package.json package-lock.json ./
COPY entrypoint.sh ./
# CMD [ "node", "dist/main" ]
# CMD [ "node", "dist/main" ]

View File

@@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion flutter.compileSdkVersion
compileSdkVersion 33
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8

View File

@@ -12,15 +12,26 @@
</intent-filter>
</activity>
<activity
android:name="com.linusu.flutter_web_auth.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="app.immich" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
<!-- Disables default WorkManager initialization to use our custom initialization -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove"></provider>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 54,
"android.injected.version.name" => "1.35.0",
"android.injected.version.code" => 56,
"android.injected.version.name" => "1.36.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1,3 @@
* Added OAuth login option
* Tidy-up dependencies, remove unused, replace rarely used ones
* Added view LivePhotos feature

View File

@@ -0,0 +1,2 @@
* Fixed freezed splash screen
* Fixed OIDC redirect but not logging in

View File

@@ -15,7 +15,7 @@ Once set up, this app can be used as photo and video backup solution directly fr
* 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)
* Reverse geocoding from image exif data
* Show asset's location information on map (OpenStreetMap).
* Show curated places on the search page
* Show curated objects on the search page

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000345">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="123.14891">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.270764">
</testcase>

View File

@@ -1,3 +1,3 @@
This is a client app for Immich Server and you will need to run/manage the server on your own in order to use the app.
Github URL: https://github.com/alextran1502/immich
Github URL: https://github.com/immich-app/immich

View File

@@ -109,7 +109,9 @@
"login_form_err_invalid_email": "Invalid Email",
"login_form_err_leading_whitespace": "Leading whitespace",
"login_form_err_trailing_whitespace": "Trailing whitespace",
"login_form_failed_login": "Error logging you in, check server url, email and password",
"login_form_failed_login": "Error logging you in, check server URL, email and password",
"login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL",
"login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server",
"login_form_label_email": "Email",
"login_form_label_password": "Password",
"login_form_password_hint": "password",

View File

@@ -1,6 +1,9 @@
{
"album_info_card_backup_album_excluded": "제외됨",
"album_info_card_backup_album_included": "포함됨",
"album_thumbnail_card_item": "1개 항목",
"album_thumbnail_card_items": "{}개 항목",
"album_thumbnail_card_shared": " · 공유",
"album_viewer_appbar_share_delete": "앨범 삭제",
"album_viewer_appbar_share_err_delete": "앨범 삭제 실패",
"album_viewer_appbar_share_err_leave": "앨범에서 나가지 못했습니다",
@@ -9,6 +12,8 @@
"album_viewer_appbar_share_leave": "앨범 나가기",
"album_viewer_appbar_share_remove": "앨범에서 제거",
"album_viewer_page_share_add_users": "사용자 추가",
"asset_list_settings_subtitle": "사진 배열 레이아웃 설정",
"asset_list_settings_title": "사진 배열",
"backup_album_selection_page_albums_device": "기기의 앨범({})",
"backup_album_selection_page_albums_tap": "포함하려면 탭하고 제외하려면 두 번 탭하세요",
"backup_album_selection_page_assets_scatter": "미디어파일은 여러 앨범에 분산될 수 있습니다. 따라서 백업 프로세스 중에 앨범에서 포함하거나 제외할 수 있습니다.",
@@ -16,25 +21,29 @@
"backup_album_selection_page_selection_info": "선택 정보",
"backup_album_selection_page_total_assets": "총 미디어파일 수",
"backup_all": "모두",
"backup_background_service_default_notification": "새 미디어파일 확인중...",
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
"backup_background_service_in_progress_notification": "미디어파일 백업 중...",
"backup_background_service_current_upload_notification": "{} 업로드 중",
"backup_background_service_error_title": "백업 오류",
"backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_backup_failed_message": "미디어파일을 백업하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_connection_failed_message": "서버에 연결하지 못했습니다. 다시 시도하는 중...",
"backup_background_service_current_upload_notification": "{} 업로드 중",
"backup_background_service_default_notification": "새 미디어파일 확인중...",
"backup_background_service_error_title": "백업 오류",
"backup_background_service_in_progress_notification": "미디어파일 백업 중...",
"backup_background_service_upload_failure_notification": "{} 업로드 실패",
"backup_controller_page_albums": "백업대상",
"backup_controller_page_background_battery_info_link": "사용 가이드",
"backup_controller_page_background_battery_info_message": "최상의 백업 환경을 위해 Immich 앱의 백그라운드 활동을 제한하는 배터리 최적화기능을 꺼주세요.\n\n휴대폰마다 설정방법이 다르므로 제조업체별로 설정방법을 확인하세요.",
"backup_controller_page_background_battery_info_ok": "확인",
"backup_controller_page_background_battery_info_title": "배터리 최적화",
"backup_controller_page_background_charging": "충전 중일 때만",
"backup_controller_page_background_configure_error": "백그라운드 서비스를 구성하지 못했습니다",
"backup_controller_page_background_description": "백그라운드 서비스를 켜서 앱을 열지 않고도 새 미디어파일을 자동으로 백업합니다.",
"backup_controller_page_background_is_off": "자동 백그라운드 백업이 꺼져 있습니다",
"backup_controller_page_background_is_on": "자동 백그라운드 백업이 켜져 있습니다",
"backup_controller_page_background_turn_off": "백그라운드 서비스 끄기",
"backup_controller_page_background_turn_on": "백그라운드 서비스 켜기",
"backup_controller_page_background_wifi": "WiFi에서만",
"backup_controller_page_backup": "백업",
"backup_controller_page_backup_selected": "선택됨: ",
"backup_controller_page_backup_sub": "백업된 사진 및 비디오",
"backup_controller_page_background_description": "백그라운드 서비스를 켜서 앱을 열지 않고도 새 미디어파일을 자동으로 백업합니다.",
"backup_controller_page_background_wifi": "WiFi에서만",
"backup_controller_page_background_charging": "충전 중일 때만",
"backup_controller_page_background_is_on": "자동 백그라운드 백업이 켜져 있습니다",
"backup_controller_page_background_is_off": "자동 백그라운드 백업이 꺼져 있습니다",
"backup_controller_page_background_turn_on": "백그라운드 서비스 켜기",
"backup_controller_page_background_turn_off": "백그라운드 서비스 끄기",
"backup_controller_page_background_configure_error": "백그라운드 서비스를 구성하지 못했습니다",
"backup_controller_page_cancel": "취소",
"backup_controller_page_created": "생성일: {}",
"backup_controller_page_desc_backup": "새 미디어파일을 서버에 자동으로 업로드하려면 백업을 켜주세요.",
@@ -60,9 +69,28 @@
"backup_controller_page_uploading_file_info": "파일 정보 업로드 중",
"backup_err_only_album": "유일한 앨범은 제거할 수 없습니다",
"backup_info_card_assets": "미디어",
"cache_settings_album_thumbnails": "라이브러리 페이지 썸네일 ({} 미디어)",
"cache_settings_clear_cache_button": "캐시 지우기",
"cache_settings_clear_cache_button_title": "앱의 캐시를 지웁니다. 이 작업은 캐시가 다시 빌드될 때까지 앱의 성능에 상당한 영향을 미칩니다.",
"cache_settings_image_cache_size": "이미재 캐시 크기 ({} 미디어)",
"cache_settings_statistics_album": "라이브러리 썸네일",
"cache_settings_statistics_assets": "{} 미디어 ({})",
"cache_settings_statistics_full": "전체 이미지",
"cache_settings_statistics_shared": "공유 앨범 썸네일",
"cache_settings_statistics_thumbnail": "썸네일",
"cache_settings_statistics_title": "캐시 사용률",
"cache_settings_subtitle": "Immich 앱의 캐싱 동작 제어",
"cache_settings_thumbnail_size": "썸네일 캐시 크기 ({} 미디어)",
"cache_settings_title": "캐시 설정",
"control_bottom_app_bar_add_to_album": "앨범에 추가",
"control_bottom_app_bar_album_info": "{} 항목",
"control_bottom_app_bar_album_info_shared": "{} 항목 · 공유됨",
"control_bottom_app_bar_create_new_album": "앨범 생성",
"control_bottom_app_bar_delete": "삭제",
"create_shared_album_page_share": "공유",
"control_bottom_app_bar_share": "공유",
"create_album_page_untitled": "제목없음",
"create_shared_album_page_create": "만들기",
"create_shared_album_page_share": "공유",
"create_shared_album_page_share_add_assets": "사진 추가",
"create_shared_album_page_share_select_photos": "사진 선택",
"daily_title_text_date": "E, M월 d일",
@@ -75,6 +103,12 @@
"exif_bottom_sheet_description": "설명 추가...",
"exif_bottom_sheet_details": "상세정보",
"exif_bottom_sheet_location": "위치",
"experimental_settings_subtitle": "문제시 책임지지 않습니다!",
"experimental_settings_title": "실험적기능",
"home_page_add_to_album_conflicts": "{album} 앨범에 {added} 미디어를 추가했습니다. {failed} 이미 앨범에 있는 항목입니다.",
"home_page_add_to_album_success": "{album} 앨범에 {added} 미디어를 추가했습니다. ",
"library_page_albums": "앨범",
"library_page_new_album": "새 앨범",
"login_form_button_text": "로그인",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "https://your-server-ip:port/api",
@@ -90,8 +124,8 @@
"login_form_save_login": "로그인상태 유지",
"monthly_title_text_date_format": "y년 M월",
"profile_drawer_client_server_up_to_date": "클라이언트와 서버가 최신 상태입니다",
"profile_drawer_sign_out": "로그아웃",
"profile_drawer_settings": "설정",
"profile_drawer_sign_out": "로그아웃",
"search_bar_hint": "사진 검색",
"search_page_no_objects": "발견된 사물이\n없습니다",
"search_page_no_places": "발견된 장소가\n없습니다",
@@ -101,52 +135,47 @@
"select_additional_user_for_sharing_page_suggestions": "초대 가능한 사용자 제안",
"select_user_for_sharing_page_err_album": "앨범 생성 실패",
"select_user_for_sharing_page_share_suggestions": "초대 가능한 사용자 제안",
"setting_notifications_notify_failures_grace_period": "백그라운드 백업 실패 알림: {}",
"setting_notifications_notify_hours": "{}시간 뒤",
"setting_notifications_notify_immediately": "즉시",
"setting_notifications_notify_minutes": "{}분 뒤",
"setting_notifications_notify_never": "알리지 않음",
"setting_notifications_single_progress_subtitle": "미디어별 상세 진행률 표시",
"setting_notifications_single_progress_title": "백그라운드 작업 세부 진행률 표시",
"setting_notifications_subtitle": "알림 기본 설정 조정",
"setting_notifications_title": "알림",
"setting_notifications_total_progress_subtitle": "전체 업로드 진행률(완료/전체)",
"setting_notifications_total_progress_title": "백그라운드 작업 전체 진행률 표시",
"setting_pages_app_bar_settings": "설정",
"share_add": "추가",
"share_add_photos": "사진 추가",
"share_add_title": "새 앨범제목",
"share_create_album": "앨범 만들기",
"share_dialog_preparing": "준비중...",
"share_invite": "앨범에 초대",
"sharing_page_album": "공유앨범",
"sharing_page_description": "공유앨범을 만들어 다른 사용자들과 사진 및 비디오를 공유합니다.",
"sharing_page_empty_list": "공유앨범 없음",
"sharing_silver_appbar_create_shared_album": "공유앨범 만들기",
"sharing_silver_appbar_share_partner": "파트너와 공유",
"tab_controller_nav_library": "라이브러리",
"tab_controller_nav_photos": "사진",
"tab_controller_nav_search": "검색",
"tab_controller_nav_sharing": "공유",
"tab_controller_nav_library": "라이브러리",
"theme_setting_asset_list_storage_indicator_title": "미디어 타일에 스토리지 싱크여부 표시",
"theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 미디어 수 ({})",
"theme_setting_dark_mode_switch": "다크모드",
"theme_setting_image_viewer_quality_subtitle": "디테일 이미지 뷰어 품질 조정",
"theme_setting_image_viewer_quality_title": "이미지 뷰어 품질",
"theme_setting_system_theme_switch": "자동(시스템 설정에 따름)",
"theme_setting_theme_subtitle": "앱테마 선택",
"theme_setting_theme_title": "테마",
"theme_setting_three_stage_loading_subtitle": "이 기능은 로딩 성능을 향상시킬 수 있지만 훨씬 더 많은 데이터를 사용합니다.",
"theme_setting_three_stage_loading_title": "3단계 로딩 활성화",
"version_announcement_overlay_ack": "승인",
"version_announcement_overlay_release_notes": "릴리스 정보",
"version_announcement_overlay_text_1": "안녕하세요!",
"version_announcement_overlay_text_2": "앱에 새로운 업데이트가 있습니다!",
"version_announcement_overlay_text_3": "특히 WatchTower 또는 서버 응용 프로그램 자동 업데이트를 처리하는 메커니즘을 사용하는 경우 잘못된 구성을 방지하기 위해 docker-compose 및 .env 설정이 최신 상태인지 확인하세요.",
"version_announcement_overlay_title": "새 서버 버전 사용 가능 \uD83C\uDF89",
"album_thumbnail_card_item": "1개 항목",
"album_thumbnail_card_items": "{}개 항목",
"album_thumbnail_card_shared": " · 공유",
"library_page_albums": "앨범",
"library_page_new_album": "새 앨범",
"create_album_page_untitled": "제목없음",
"share_dialog_preparing": "준비중...",
"control_bottom_app_bar_share": "공유",
"setting_pages_app_bar_settings": "설정",
"theme_setting_theme_title": "테마",
"theme_setting_theme_subtitle": "앱테마 선택",
"theme_setting_system_theme_switch": "자동(시스템 설정에 따름)",
"theme_setting_dark_mode_switch": "다크모드",
"theme_setting_image_viewer_quality_title": "이미지 뷰어 품질",
"theme_setting_image_viewer_quality_subtitle": "디테일 이미지 뷰어 품질 조정",
"theme_setting_three_stage_loading_title": "3단계 로딩 활성화",
"theme_setting_three_stage_loading_subtitle": "이 기능은 로딩 성능을 향상시킬 수 있지만 훨씬 더 많은 데이터를 사용합니다.",
"asset_list_settings_title": "사진 배열",
"asset_list_settings_subtitle": "사진 배열 레이아웃 설정",
"theme_setting_asset_list_storage_indicator_title": "미디어 타일에 스토리지 싱크여부 표시",
"theme_setting_asset_list_tiles_per_row_title": "한 줄에 표시할 미디어 수 ({})",
"setting_notifications_title": "알림",
"setting_notifications_subtitle": "알림 기본 설정 조정",
"setting_notifications_notify_failures_grace_period": "백그라운드 백업 실패 알림: {}",
"setting_notifications_notify_immediately": "즉시",
"setting_notifications_notify_minutes": "{}분 뒤",
"setting_notifications_notify_hours": "{}시간 뒤",
"setting_notifications_notify_never": "알리지 않음"
"version_announcement_overlay_title": "새 서버 버전 사용 가능 🎉"
}

View File

@@ -3,6 +3,8 @@ PODS:
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_web_auth (0.5.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
- Toast
@@ -37,6 +39,7 @@ PODS:
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@@ -60,6 +63,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_web_auth:
:path: ".symlinks/plugins/flutter_web_auth/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
image_picker_ios:
@@ -86,6 +91,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.34.0</string>
<string>1.36.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>68</string>
<string>72</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -0,0 +1,23 @@
#!/bin/sh
# The default execution directory of this script is the ci_scripts directory.
cd $CI_WORKSPACE/mobile
# Install Flutter using git.
git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter
export PATH="$PATH:$HOME/flutter/bin"
# Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms.
flutter precache --ios
# Install Flutter dependencies.
flutter pub get
# Install CocoaPods using Homebrew.
HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
brew install cocoapods
# Install CocoaPods dependencies.
cd ios && pod install # run `pod install` in the `ios` directory.
exit 0

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.35.0"
version_number: "1.36.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000304">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000358">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.606546">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.721922">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.706234">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.015111">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.657686">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.656945">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="68.78265">
<testcase classname="fastlane.lanes" name="4: build_app" time="75.686541">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="60.883182">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.644406">
</testcase>

View File

@@ -69,7 +69,10 @@ class AlbumService {
Iterable<Asset> assets,
) async {
return createAlbum(
_getNextAlbumName(await getAlbums(isShared: false)), assets, []);
_getNextAlbumName(await getAlbums(isShared: false)),
assets,
[],
);
}
Future<AlbumResponseDto?> getAlbumDetail(String albumId) async {

View File

@@ -34,7 +34,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void _onDeleteAlbumPressed(String albumId) async {
void onDeleteAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess =
@@ -62,7 +62,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
ImmichLoadingOverlayController.appLoader.hide();
}
void _onLeaveAlbumPressed(String albumId) async {
void onLeaveAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess =
@@ -84,7 +84,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
ImmichLoadingOverlayController.appLoader.hide();
}
void _onRemoveFromAlbumPressed(String albumId) async {
void onRemoveFromAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess =
@@ -110,7 +110,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
ImmichLoadingOverlayController.appLoader.hide();
}
_buildBottomSheetActionButton() {
buildBottomSheetActionButton() {
if (isMultiSelectionEnable) {
if (albumInfo.ownerId == userId) {
return ListTile(
@@ -119,7 +119,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
'album_viewer_appbar_share_remove',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => _onRemoveFromAlbumPressed(albumId),
onTap: () => onRemoveFromAlbumPressed(albumId),
);
} else {
return const SizedBox();
@@ -132,7 +132,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => _onDeleteAlbumPressed(albumId),
onTap: () => onDeleteAlbumPressed(albumId),
);
} else {
return ListTile(
@@ -141,13 +141,13 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => _onLeaveAlbumPressed(albumId),
onTap: () => onLeaveAlbumPressed(albumId),
);
}
}
}
void _buildBottomSheet() {
void buildBottomSheet() {
showModalBottomSheet(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
isScrollControlled: false,
@@ -157,7 +157,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBottomSheetActionButton(),
buildBottomSheetActionButton(),
],
),
);
@@ -165,7 +165,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
);
}
_buildLeadingButton() {
buildLeadingButton() {
if (isMultiSelectionEnable) {
return IconButton(
onPressed: () => ref
@@ -204,7 +204,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
return AppBar(
elevation: 0,
leading: _buildLeadingButton(),
leading: buildLeadingButton(),
title: isMultiSelectionEnable
? Text('${selectedAssetsInAlbum.length}')
: null,
@@ -212,7 +212,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
actions: [
IconButton(
splashRadius: 25,
onPressed: _buildBottomSheet,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],

View File

@@ -27,7 +27,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
final isMultiSelectionEnable =
ref.watch(assetSelectionProvider).isMultiselectEnable;
_viewAsset() {
viewAsset() {
AutoRouter.of(context).push(
GalleryViewerRoute(
asset: asset,
@@ -47,18 +47,18 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
}
}
_enableMultiSelection() {
enableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
ref
.watch(assetSelectionProvider.notifier)
.addAssetsInAlbumViewer([asset]);
}
_disableMultiSelection() {
disableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
}
_buildVideoLabel() {
buildVideoLabel() {
return Positioned(
top: 5,
right: 5,
@@ -80,7 +80,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
);
}
_buildAssetStoreLocationIcon() {
buildAssetStoreLocationIcon() {
return Positioned(
right: 10,
bottom: 5,
@@ -94,7 +94,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
);
}
_buildAssetSelectionIcon() {
buildAssetSelectionIcon() {
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
return Positioned(
@@ -112,21 +112,21 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
);
}
_buildThumbnailImage() {
buildThumbnailImage() {
return Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: ImmichImage(asset, width: 300, height: 300),
);
}
_handleSelectionGesture() {
handleSelectionGesture() {
if (selectedAssetsInAlbumViewer.contains(asset)) {
ref
.watch(assetSelectionProvider.notifier)
.removeAssetsInAlbumViewer([asset]);
if (selectedAssetsInAlbumViewer.isEmpty) {
_disableMultiSelection();
disableMultiSelection();
}
} else {
ref
@@ -136,14 +136,14 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
}
return GestureDetector(
onTap: isMultiSelectionEnable ? _handleSelectionGesture : _viewAsset,
onLongPress: _enableMultiSelection,
onTap: isMultiSelectionEnable ? handleSelectionGesture : viewAsset,
onLongPress: enableMultiSelection,
child: Stack(
children: [
_buildThumbnailImage(),
if (showStorageIndicator) _buildAssetStoreLocationIcon(),
if (!asset.isImage) _buildVideoLabel(),
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
buildThumbnailImage(),
if (showStorageIndicator) buildAssetStoreLocationIcon(),
if (!asset.isImage) buildVideoLabel(),
if (isMultiSelectionEnable) buildAssetSelectionIcon(),
],
),
);

View File

@@ -21,7 +21,7 @@ class MonthGroupTitle extends HookConsumerWidget {
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
_handleTitleIconClick() {
handleTitleIconClick() {
HapticFeedback.heavyImpact();
if (isAlbumExist) {
@@ -61,7 +61,7 @@ class MonthGroupTitle extends HookConsumerWidget {
}
}
_getSimplifiedMonth() {
getSimplifiedMonth() {
var monthAndYear = month.split(',');
var yearText = monthAndYear[1].trim();
var monthText = monthAndYear[0].trim();
@@ -85,7 +85,7 @@ class MonthGroupTitle extends HookConsumerWidget {
child: Row(
children: [
GestureDetector(
onTap: _handleTitleIconClick,
onTap: handleTitleIconClick,
child: selectedDateGroup.contains(month)
? Icon(
Icons.check_circle_rounded,
@@ -97,11 +97,11 @@ class MonthGroupTitle extends HookConsumerWidget {
),
),
GestureDetector(
onTap: _handleTitleIconClick,
onTap: handleTitleIconClick,
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
_getSimplifiedMonth(),
getSimplifiedMonth(),
style: TextStyle(
fontSize: 24,
color: Theme.of(context).primaryColor,

View File

@@ -18,7 +18,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
Widget _buildSelectionIcon(Asset asset) {
Widget buildSelectionIcon(Asset asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
@@ -111,7 +111,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset),
child: buildSelectionIcon(asset),
),
),
if (!asset.isImage)

View File

@@ -37,7 +37,7 @@ class AlbumViewerPage extends HookConsumerWidget {
/// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected.
void _onAddPhotosPressed(AlbumResponseDto albumInfo) async {
void onAddPhotosPressed(AlbumResponseDto albumInfo) async {
if (albumInfo.assets.isNotEmpty == true) {
ref.watch(assetSelectionProvider.notifier).addNewAssets(
albumInfo.assets.map((e) => Asset.remote(e)).toList(),
@@ -60,7 +60,8 @@ class AlbumViewerPage extends HookConsumerWidget {
albumId,
);
if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
if (addAssetsResult != null &&
addAssetsResult.successfullyAdded > 0) {
ref.refresh(sharedAlbumDetailProvider(albumId));
}
@@ -73,7 +74,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
void _onAddUsersPressed(AlbumResponseDto albumInfo) async {
void onAddUsersPressed(AlbumResponseDto albumInfo) async {
List<String>? sharedUserIds =
await AutoRouter.of(context).push<List<String>?>(
SelectAdditionalUserForSharingRoute(albumInfo: albumInfo),
@@ -94,7 +95,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
}
Widget _buildTitle(AlbumResponseDto albumInfo) {
Widget buildTitle(AlbumResponseDto albumInfo) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: userId == albumInfo.ownerId
@@ -115,7 +116,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget _buildAlbumDateRange(AlbumResponseDto albumInfo) {
Widget buildAlbumDateRange(AlbumResponseDto albumInfo) {
String startDate = "";
DateTime parsedStartDate =
DateTime.parse(albumInfo.assets.first.createdAt);
@@ -148,14 +149,14 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget _buildHeader(AlbumResponseDto albumInfo) {
Widget buildHeader(AlbumResponseDto albumInfo) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(albumInfo),
buildTitle(albumInfo),
if (albumInfo.assets.isNotEmpty == true)
_buildAlbumDateRange(albumInfo),
buildAlbumDateRange(albumInfo),
if (albumInfo.shared)
SizedBox(
height: 60,
@@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget _buildImageGrid(AlbumResponseDto albumInfo) {
Widget buildImageGrid(AlbumResponseDto albumInfo) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
@@ -220,7 +221,7 @@ class AlbumViewerPage extends HookConsumerWidget {
return const SliverToBoxAdapter();
}
Widget _buildControlButton(AlbumResponseDto albumInfo) {
Widget buildControlButton(AlbumResponseDto albumInfo) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
child: SizedBox(
@@ -230,13 +231,13 @@ class AlbumViewerPage extends HookConsumerWidget {
children: [
AlbumActionOutlinedButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: () => _onAddPhotosPressed(albumInfo),
onPressed: () => onAddPhotosPressed(albumInfo),
labelText: "share_add_photos".tr(),
),
if (userId == albumInfo.ownerId)
AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => _onAddUsersPressed(albumInfo),
onPressed: () => onAddUsersPressed(albumInfo),
labelText: "album_viewer_page_share_add_users".tr(),
),
],
@@ -245,7 +246,7 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
Widget _buildBody(AlbumResponseDto albumInfo) {
Widget buildBody(AlbumResponseDto albumInfo) {
return GestureDetector(
onTap: () {
titleFocusNode.unfocus();
@@ -257,7 +258,7 @@ class AlbumViewerPage extends HookConsumerWidget {
child: CustomScrollView(
controller: scrollController,
slivers: [
_buildHeader(albumInfo),
buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
@@ -265,11 +266,11 @@ class AlbumViewerPage extends HookConsumerWidget {
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: _buildControlButton(albumInfo),
child: buildControlButton(albumInfo),
),
),
),
_buildImageGrid(albumInfo)
buildImageGrid(albumInfo)
],
),
),
@@ -293,7 +294,7 @@ class AlbumViewerPage extends HookConsumerWidget {
),
body: albumInfo.when(
data: (albumInfo) => albumInfo != null
? _buildBody(albumInfo)
? buildBody(albumInfo)
: const Center(
child: CircularProgressIndicator(),
),

View File

@@ -25,7 +25,7 @@ class AssetSelectionPage extends HookConsumerWidget {
List<Widget> imageGridGroup = [];
String _buildAssetCountText() {
String buildAssetCountText() {
if (isAlbumExist) {
return (selectedAssets.length + newAssetsForAlbum.length).toString();
} else {
@@ -33,7 +33,7 @@ class AssetSelectionPage extends HookConsumerWidget {
}
}
Widget _buildBody() {
Widget buildBody() {
assetGroupMonthYear.forEach((monthYear, assetGroup) {
imageGridGroup
.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
@@ -71,7 +71,7 @@ class AssetSelectionPage extends HookConsumerWidget {
style: TextStyle(fontSize: 18),
).tr()
: Text(
_buildAssetCountText(),
buildAssetCountText(),
style: const TextStyle(fontSize: 18),
),
centerTitle: false,
@@ -94,7 +94,7 @@ class AssetSelectionPage extends HookConsumerWidget {
),
],
),
body: _buildBody(),
body: buildBody(),
);
}
}

View File

@@ -29,11 +29,11 @@ class CreateAlbumPage extends HookConsumerWidget {
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
_showSelectUserPage() {
showSelectUserPage() {
AutoRouter.of(context).push(const SelectUserForSharingRoute());
}
void _onBackgroundTapped() {
void onBackgroundTapped() {
albumTitleTextFieldFocusNode.unfocus();
isAlbumTitleTextFieldFocus.value = false;
@@ -45,7 +45,7 @@ class CreateAlbumPage extends HookConsumerWidget {
}
}
_onSelectPhotosButtonPressed() async {
onSelectPhotosButtonPressed() async {
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
@@ -56,7 +56,7 @@ class CreateAlbumPage extends HookConsumerWidget {
}
}
_buildTitleInputField() {
buildTitleInputField() {
return Padding(
padding: const EdgeInsets.only(
right: 10,
@@ -71,7 +71,7 @@ class CreateAlbumPage extends HookConsumerWidget {
);
}
_buildTitle() {
buildTitle() {
if (selectedAssets.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
@@ -90,7 +90,7 @@ class CreateAlbumPage extends HookConsumerWidget {
return const SliverToBoxAdapter();
}
_buildSelectPhotosButton() {
buildSelectPhotosButton() {
if (selectedAssets.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
@@ -109,7 +109,7 @@ class CreateAlbumPage extends HookConsumerWidget {
borderRadius: BorderRadius.circular(5),
),
),
onPressed: _onSelectPhotosButtonPressed,
onPressed: onSelectPhotosButtonPressed,
icon: Icon(
Icons.add_rounded,
color: Theme.of(context).primaryColor,
@@ -132,7 +132,7 @@ class CreateAlbumPage extends HookConsumerWidget {
return const SliverToBoxAdapter();
}
_buildControlButton() {
buildControlButton() {
return Padding(
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
child: SizedBox(
@@ -142,7 +142,7 @@ class CreateAlbumPage extends HookConsumerWidget {
children: [
AlbumActionOutlinedButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: _onSelectPhotosButtonPressed,
onPressed: onSelectPhotosButtonPressed,
labelText: "share_add_photos".tr(),
),
],
@@ -151,7 +151,7 @@ class CreateAlbumPage extends HookConsumerWidget {
);
}
_buildSelectedImageGrid() {
buildSelectedImageGrid() {
if (selectedAssets.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 16),
@@ -164,7 +164,7 @@ class CreateAlbumPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return GestureDetector(
onTap: _onBackgroundTapped,
onTap: onBackgroundTapped,
child: SharedAlbumThumbnailImage(
asset: selectedAssets.elementAt(index),
),
@@ -179,7 +179,7 @@ class CreateAlbumPage extends HookConsumerWidget {
return const SliverToBoxAdapter();
}
_createNonSharedAlbum() async {
createNonSharedAlbum() async {
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
@@ -216,7 +216,7 @@ class CreateAlbumPage extends HookConsumerWidget {
if (isSharedAlbum)
TextButton(
onPressed: albumTitleController.text.isNotEmpty
? _showSelectUserPage
? showSelectUserPage
: null,
child: Text(
'create_shared_album_page_share'.tr(),
@@ -230,7 +230,7 @@ class CreateAlbumPage extends HookConsumerWidget {
TextButton(
onPressed: albumTitleController.text.isNotEmpty &&
selectedAssets.isNotEmpty
? _createNonSharedAlbum
? createNonSharedAlbum
: null,
child: Text(
'create_shared_album_page_create'.tr(),
@@ -242,7 +242,7 @@ class CreateAlbumPage extends HookConsumerWidget {
],
),
body: GestureDetector(
onTap: _onBackgroundTapped,
onTap: onBackgroundTapped,
child: CustomScrollView(
slivers: [
SliverAppBar(
@@ -255,15 +255,15 @@ class CreateAlbumPage extends HookConsumerWidget {
preferredSize: const Size.fromHeight(66.0),
child: Column(
children: [
_buildTitleInputField(),
if (selectedAssets.isNotEmpty) _buildControlButton(),
buildTitleInputField(),
if (selectedAssets.isNotEmpty) buildControlButton(),
],
),
),
),
_buildTitle(),
_buildSelectPhotosButton(),
_buildSelectedImageGrid(),
buildTitle(),
buildSelectPhotosButton(),
buildSelectedImageGrid(),
],
),
),

View File

@@ -19,12 +19,12 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
ref.watch(suggestedSharedUsersProvider);
final sharedUsersList = useState<Set<UserResponseDto>>({});
_addNewUsersHandler() {
addNewUsersHandler() {
AutoRouter.of(context)
.pop(sharedUsersList.value.map((e) => e.id).toList());
}
_buildTileIcon(UserResponseDto user) {
buildTileIcon(UserResponseDto user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
@@ -42,7 +42,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
}
}
_buildUserList(List<UserResponseDto> users) {
buildUserList(List<UserResponseDto> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {
@@ -84,7 +84,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
shrinkWrap: true,
itemBuilder: ((context, index) {
return ListTile(
leading: _buildTileIcon(users[index]),
leading: buildTileIcon(users[index]),
title: Text(
users[index].email,
style: const TextStyle(
@@ -131,7 +131,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
actions: [
TextButton(
onPressed:
sharedUsersList.value.isEmpty ? null : _addNewUsersHandler,
sharedUsersList.value.isEmpty ? null : addNewUsersHandler,
child: const Text(
"share_add",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
@@ -147,7 +147,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
);
}
return _buildUserList(users);
return buildUserList(users);
},
error: (e, _) => Text("Error loading suggested users $e"),
loading: () => const Center(

View File

@@ -20,7 +20,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
AsyncValue<List<UserResponseDto>> suggestedShareUsers =
ref.watch(suggestedSharedUsersProvider);
_createSharedAlbum() async {
createSharedAlbum() async {
var newAlbum =
await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
ref.watch(albumTitleProvider),
@@ -44,7 +44,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
);
}
_buildTileIcon(UserResponseDto user) {
buildTileIcon(UserResponseDto user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
@@ -62,7 +62,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
}
}
_buildUserList(List<UserResponseDto> users) {
buildUserList(List<UserResponseDto> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {
@@ -104,7 +104,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
shrinkWrap: true,
itemBuilder: ((context, index) {
return ListTile(
leading: _buildTileIcon(users[index]),
leading: buildTileIcon(users[index]),
title: Text(
users[index].email,
style: const TextStyle(
@@ -153,8 +153,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).primaryColor,
),
onPressed:
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
onPressed: sharedUsersList.value.isEmpty ? null : createSharedAlbum,
child: const Text(
"share_create_album",
style: TextStyle(
@@ -168,7 +167,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
),
body: suggestedShareUsers.when(
data: (users) {
return _buildUserList(users);
return buildUserList(users);
},
error: (e, _) => Text("Error loading suggested users $e"),
loading: () => const Center(

View File

@@ -28,7 +28,7 @@ class SharingPage extends HookConsumerWidget {
[],
);
_buildAlbumList() {
buildAlbumList() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
@@ -71,7 +71,7 @@ class SharingPage extends HookConsumerWidget {
);
}
_buildEmptyListIndication() {
buildEmptyListIndication() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8.0),
@@ -136,8 +136,8 @@ class SharingPage extends HookConsumerWidget {
),
),
sharedAlbums.isNotEmpty
? _buildAlbumList()
: _buildEmptyListIndication()
? buildAlbumList()
: buildEmptyListIndication()
],
),
);

View File

@@ -64,5 +64,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider), ref.watch(shareServiceProvider))),
ref.watch(imageViewerServiceProvider),
ref.watch(shareServiceProvider),
)),
);

View File

@@ -22,28 +22,58 @@ class ImageViewerService {
try {
String fileName = p.basename(asset.originalPath);
var res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.deviceAssetId,
asset.deviceId,
isThumb: false,
isWeb: false,
);
// Download LivePhotos image and motion part
if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) {
var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id,
isThumb: false,
isWeb: false,
);
final AssetEntity? entity;
var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.livePhotoVideoId!,
isThumb: false,
isWeb: false,
);
if (asset.type == AssetTypeEnum.IMAGE) {
entity = await PhotoManager.editor.saveImage(
res.bodyBytes,
final AssetEntity? entity;
final tempDir = await getTemporaryDirectory();
File videoFile = await File('${tempDir.path}/livephoto.mov').create();
File imageFile = await File('${tempDir.path}/livephoto.heic').create();
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile,
videoFile: videoFile,
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);
}
return entity != null;
return entity != null;
} else {
var res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id,
isThumb: false,
isWeb: false,
);
final AssetEntity? entity;
if (asset.type == AssetTypeEnum.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);
}
return entity != null;
}
} catch (e) {
debugPrint("Error saving file $e");
return false;

View File

@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
class DownloadLoadingIndicator extends StatelessWidget {
const DownloadLoadingIndicator({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 60,
width: 60,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(10),
),
child: const SpinKitDancingSquare(
color: Colors.white,
size: 30.0,
),
);
}
}

View File

@@ -15,7 +15,7 @@ class ExifBottomSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
_buildMap() {
buildMap() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
@@ -66,7 +66,7 @@ class ExifBottomSheet extends ConsumerWidget {
ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
_buildLocationText() {
buildLocationText() {
return Text(
"${exifInfo?.city}, ${exifInfo?.state}",
style: TextStyle(
@@ -120,11 +120,11 @@ class ExifBottomSheet extends ConsumerWidget {
).tr(),
if (assetDetail.latitude != null &&
assetDetail.longitude != null)
_buildMap(),
buildMap(),
if (exifInfo != null &&
exifInfo.city != null &&
exifInfo.state != null)
_buildLocationText(),
buildLocationText(),
Text(
"${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
style: TextStyle(fontSize: 12, color: Colors.grey[400]),

View File

@@ -37,7 +37,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}
void handleSwipUpDown(PointerMoveEvent details) {
int sensitivity = 10;
int sensitivity = 15;
if (_zoomedIn) {
return;

View File

@@ -3,21 +3,23 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
const TopControlAppBar({
Key? key,
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onSharePressed,
this.loading = false,
required this.onToggleMotionVideo,
required this.isPlayingMotionVideo,
}) : super(key: key);
final Asset asset;
final Function onMoreInfoPressed;
final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo;
final Function onSharePressed;
final bool loading;
final bool isPlayingMotionVideo;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -38,14 +40,22 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
),
),
actions: [
if (loading)
Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 15.0),
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2.0),
),
if (asset.remote?.livePhotoVideoId != null)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onToggleMotionVideo();
},
icon: isPlayingMotionVideo
? Icon(
Icons.motion_photos_pause_outlined,
color: Colors.grey[200],
)
: Icon(
Icons.play_circle_outline_rounded,
color: Colors.grey[200],
),
),
if (!asset.isLocal)
IconButton(
@@ -79,7 +89,7 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
Icons.more_horiz_rounded,
color: Colors.grey[200],
),
)
),
],
);
}

View File

@@ -2,7 +2,6 @@ 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:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -34,10 +33,10 @@ class GalleryViewerPage extends HookConsumerWidget {
final Box<dynamic> box = Hive.box(userInfoBox);
final appSettingService = ref.watch(appSettingsServiceProvider);
final threeStageLoading = useState(false);
final loading = useState(false);
final isZoomed = useState<bool>(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
PageController controller =
PageController(initialPage: assetList.indexOf(asset));
@@ -46,6 +45,7 @@ class GalleryViewerPage extends HookConsumerWidget {
() {
threeStageLoading.value = appSettingService
.getSetting<bool>(AppSettingsEnum.threeStageLoading);
isPlayingMotionVideo.value = false;
return null;
},
[],
@@ -86,7 +86,7 @@ class GalleryViewerPage extends HookConsumerWidget {
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
loading: loading.value,
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: assetList[indexOfAsset.value],
onMoreInfoPressed: () {
showInfo();
@@ -95,13 +95,18 @@ class GalleryViewerPage extends HookConsumerWidget {
? null
: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value].remote!, context);
assetList[indexOfAsset.value].remote!,
context,
);
},
onSharePressed: () {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset.value], context);
},
onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
),
body: SafeArea(
child: PageView.builder(
@@ -120,25 +125,43 @@ class GalleryViewerPage extends HookConsumerWidget {
getAssetExif();
if (assetList[index].isImage) {
return ImageViewerPage(
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
asset: assetList[index],
heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value,
);
if (isPlayingMotionVideo.value) {
return VideoViewerPage(
asset: assetList[index],
isMotionVideo: true,
onVideoEnded: () {
isPlayingMotionVideo.value = false;
},
);
} else {
return ImageViewerPage(
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
asset: assetList[index],
heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value,
);
}
} else {
return SwipeDetector(
onSwipeDown: (_) {
AutoRouter.of(context).pop();
},
onSwipeUp: (_) {
showInfo();
return GestureDetector(
onVerticalDragUpdate: (details) {
const int sensitivity = 15;
if (details.delta.dy > sensitivity) {
// swipe down
AutoRouter.of(context).pop();
} else if (details.delta.dy < -sensitivity) {
// swipe up
showInfo();
}
},
child: Hero(
tag: assetList[index].id,
child: VideoViewerPage(asset: assetList[index]),
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: false,
onVideoEnded: () {},
),
),
);
}

View File

@@ -4,11 +4,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/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/remote_photo_view.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
@@ -84,7 +84,7 @@ class ImageViewerPage extends HookConsumerWidget {
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: DownloadLoadingIndicator(),
child: ImmichLoadingIndicator(),
),
],
);

View File

@@ -7,23 +7,34 @@ 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/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
// ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget {
final Asset asset;
final bool isMotionVideo;
final VoidCallback onVideoEnded;
const VideoViewerPage({Key? key, required this.asset}) : super(key: key);
const VideoViewerPage({
Key? key,
required this.asset,
required this.isMotionVideo,
required this.onVideoEnded,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
if (asset.isLocal) {
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
return videoFile.when(
data: (data) => VideoThumbnailPlayer(file: data),
data: (data) => VideoThumbnailPlayer(
file: data,
isMotionVideo: false,
onVideoEnded: () {},
),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
@@ -41,18 +52,21 @@ class VideoViewerPage extends HookConsumerWidget {
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey);
final String videoUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}';
final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.id}';
return Stack(
children: [
VideoThumbnailPlayer(
url: videoUrl,
jwtToken: jwtToken,
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: DownloadLoadingIndicator(),
child: ImmichLoadingIndicator(),
),
],
);
@@ -72,9 +86,17 @@ class VideoThumbnailPlayer extends StatefulWidget {
final String? url;
final String? jwtToken;
final File? file;
final bool isMotionVideo;
final VoidCallback onVideoEnded;
const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file})
: super(key: key);
const VideoThumbnailPlayer({
Key? key,
this.url,
this.jwtToken,
this.file,
required this.onVideoEnded,
required this.isMotionVideo,
}) : super(key: key);
@override
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
@@ -88,6 +110,13 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
void initState() {
super.initState();
initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
widget.onVideoEnded();
}
});
}
Future<void> initializePlayer() async {
@@ -115,7 +144,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
autoPlay: true,
autoInitialize: true,
allowFullScreen: true,
showControls: true,
showControls: !widget.isMotionVideo,
hideControlsTimer: const Duration(seconds: 5),
);
}

View File

@@ -1,3 +1,5 @@
// ignore_for_file: implementation_imports
import 'package:flutter/foundation.dart';
import 'package:easy_localization/src/asset_loader.dart';
import 'package:easy_localization/src/easy_localization_controller.dart';

View File

@@ -1,7 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart';
class ErrorUploadAsset extends Equatable {
class ErrorUploadAsset {
final String id;
final DateTime createdAt;
final String fileName;
@@ -42,14 +41,25 @@ class ErrorUploadAsset extends Equatable {
}
@override
List<Object> get props {
return [
id,
createdAt,
fileName,
fileType,
asset,
errorMessage,
];
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ErrorUploadAsset &&
other.id == id &&
other.createdAt == createdAt &&
other.fileName == fileName &&
other.fileType == fileType &&
other.asset == asset &&
other.errorMessage == errorMessage;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
fileName.hashCode ^
fileType.hashCode ^
asset.hashCode ^
errorMessage.hashCode;
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
@@ -263,6 +264,13 @@ class BackupService {
req.files.add(assetRawUploadData);
if (entity.isLivePhoto) {
var livePhotoRawUploadData = await _getLivePhotoFile(entity);
if (livePhotoRawUploadData != null) {
req.files.add(livePhotoRawUploadData);
}
}
setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
@@ -322,6 +330,33 @@ class BackupService {
return !anyErrors;
}
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
var motionFilePath = await entity.getMediaUrl();
// var motionFilePath = '/var/mobile/Media/DCIM/103APPLE/IMG_3371.MOV'
if (motionFilePath != null) {
var validPath = motionFilePath.replaceAll('file://', '');
var motionFile = File(validPath);
var fileStream = motionFile.openRead();
String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var mimeType = FileHelper.getMimeType(validPath);
return http.MultipartFile(
"livePhotoData",
fileStream,
motionFile.lengthSync(),
filename: fileNameWithoutPath,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
);
}
return null;
}
String _getAssetType(AssetType assetType) {
switch (assetType) {
case AssetType.audio:

View File

@@ -33,7 +33,7 @@ class AlbumInfoCard extends HookConsumerWidget {
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
_buildSelectedTextBox() {
buildSelectedTextBox() {
if (isSelected) {
return Chip(
visualDensity: VisualDensity.compact,
@@ -67,7 +67,7 @@ class AlbumInfoCard extends HookConsumerWidget {
return const SizedBox();
}
_buildImageFilter() {
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
@@ -163,7 +163,7 @@ class AlbumInfoCard extends HookConsumerWidget {
topRight: Radius.circular(12),
),
image: DecorationImage(
colorFilter: _buildImageFilter(),
colorFilter: buildImageFilter(),
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
@@ -177,7 +177,7 @@ class AlbumInfoCard extends HookConsumerWidget {
Positioned(
bottom: 10,
left: 25,
child: _buildSelectedTextBox(),
child: buildSelectedTextBox(),
)
],
),

View File

@@ -15,14 +15,16 @@ class AlbumPreviewPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
_getAssetsInAlbum() async {
getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(
start: 0, end: await album.assetCountAsync);
start: 0,
end: await album.assetCountAsync,
);
}
useEffect(
() {
_getAssetsInAlbum();
getAssetsInAlbum();
return null;
},
[],

View File

@@ -27,7 +27,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
[],
);
_buildAlbumSelectionList() {
buildAlbumSelectionList() {
if (availableAlbums.isEmpty) {
return const Center(
child: ImmichLoadingIndicator(),
@@ -56,7 +56,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
);
}
_buildSelectedAlbumNameChip() {
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() {
if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
@@ -104,7 +104,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet();
}
_buildExcludedAlbumNameChip() {
buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref
@@ -177,8 +177,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
..._buildSelectedAlbumNameChip(),
..._buildExcludedAlbumNameChip()
...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip()
],
),
),
@@ -286,7 +286,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildAlbumSelectionList(),
child: buildAlbumSelectionList(),
),
],
),

View File

@@ -13,7 +13,6 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
import 'package:url_launcher/url_launcher.dart';
class BackupControllerPage extends HookConsumerWidget {
@@ -46,7 +45,7 @@ class BackupControllerPage extends HookConsumerWidget {
[],
);
Widget _buildStorageInformation() {
Widget buildStorageInformation() {
return ListTile(
leading: Icon(
Icons.storage_rounded,
@@ -63,14 +62,11 @@ class BackupControllerPage extends HookConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 10.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
child: LinearProgressIndicator(
minHeight: 10.0,
value: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
color: Theme.of(context).primaryColor,
),
),
Padding(
@@ -88,7 +84,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
ListTile _buildAutoBackupController() {
ListTile buildAutoBackupController() {
var backUpOption = authenticationState.deviceInfo.isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
@@ -147,7 +143,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
void _showErrorToUser(String msg) {
void showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
@@ -157,7 +153,7 @@ class BackupControllerPage extends HookConsumerWidget {
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void _showBatteryOptimizationInfoToUser() {
void showBatteryOptimizationInfoToUser() {
showDialog<void>(
context: context,
barrierDismissible: false,
@@ -197,7 +193,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
ListTile _buildBackgroundBackupController() {
ListTile buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
@@ -242,8 +238,8 @@ class BackupControllerPage extends HookConsumerWidget {
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: _showErrorToUser,
onBatteryInfo: _showBatteryOptimizationInfoToUser,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
)
: null,
),
@@ -263,8 +259,8 @@ class BackupControllerPage extends HookConsumerWidget {
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: _showErrorToUser,
onBatteryInfo: _showBatteryOptimizationInfoToUser,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
)
: null,
),
@@ -272,8 +268,8 @@ class BackupControllerPage extends HookConsumerWidget {
onPressed: () =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: _showErrorToUser,
onBatteryInfo: _showBatteryOptimizationInfoToUser,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
child: Text(
isBackgroundEnabled
@@ -288,7 +284,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
Widget _buildSelectedAlbumName() {
Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
@@ -327,7 +323,7 @@ class BackupControllerPage extends HookConsumerWidget {
}
}
Widget _buildExcludedAlbumName() {
Widget buildExcludedAlbumName() {
var text = "backup_controller_page_excluded".tr();
var albums = ref.watch(backupProvider).excludedBackupAlbums;
@@ -352,7 +348,7 @@ class BackupControllerPage extends HookConsumerWidget {
}
}
_buildFolderSelectionTile() {
buildFolderSelectionTile() {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
@@ -378,8 +374,8 @@ class BackupControllerPage extends HookConsumerWidget {
"backup_controller_page_to_backup",
style: TextStyle(fontSize: 12),
).tr(),
_buildSelectedAlbumName(),
_buildExcludedAlbumName()
buildSelectedAlbumName(),
buildExcludedAlbumName()
],
),
),
@@ -402,7 +398,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
_buildCurrentBackupAssetInfoCard() {
buildCurrentBackupAssetInfoCard() {
return ListTile(
leading: Icon(
Icons.info_outline_rounded,
@@ -444,17 +440,21 @@ class BackupControllerPage extends HookConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 10.0,
trailing: Text(
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
percent: backupState.progressInPercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: backupState.progressInPercentage / 100.0,
backgroundColor: Colors.grey,
color: Theme.of(context).primaryColor,
),
),
Text(
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
)
],
),
),
Padding(
@@ -606,7 +606,7 @@ class BackupControllerPage extends HookConsumerWidget {
),
),
),
_buildFolderSelectionTile(),
buildFolderSelectionTile(),
BackupInfoCard(
title: "backup_controller_page_total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
@@ -624,13 +624,13 @@ class BackupControllerPage extends HookConsumerWidget {
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
),
const Divider(),
_buildAutoBackupController(),
buildAutoBackupController(),
if (Platform.isAndroid) const Divider(),
if (Platform.isAndroid) _buildBackgroundBackupController(),
if (Platform.isAndroid) buildBackgroundBackupController(),
const Divider(),
_buildStorageInformation(),
buildStorageInformation(),
const Divider(),
_buildCurrentBackupAssetInfoCard(),
buildCurrentBackupAssetInfoCard(),
Padding(
padding: const EdgeInsets.only(
top: 24,

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/src/types/entity.dart';
import 'package:photo_manager/photo_manager.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(

View File

@@ -15,7 +15,7 @@ class ProfileDrawer extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
_buildSignoutButton() {
buildSignoutButton() {
return ListTile(
horizontalTitleGap: 0,
leading: SizedBox(
@@ -46,7 +46,7 @@ class ProfileDrawer extends HookConsumerWidget {
);
}
_buildSettingButton() {
buildSettingButton() {
return ListTile(
horizontalTitleGap: 0,
leading: SizedBox(
@@ -79,8 +79,8 @@ class ProfileDrawer extends HookConsumerWidget {
padding: EdgeInsets.zero,
children: [
const ProfileDrawerHeader(),
_buildSettingButton(),
_buildSignoutButton(),
buildSettingButton(),
buildSignoutButton(),
],
),
const ServerInfoBox()

View File

@@ -25,7 +25,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
var dummmy = Random().nextInt(1024);
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
_buildUserProfileImage() {
buildUserProfileImage() {
if (authState.profileImagePath.isEmpty) {
return const CircleAvatar(
radius: 35,
@@ -77,7 +77,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
return const SizedBox();
}
_pickUserProfileImage() async {
pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxHeight: 1024,
@@ -98,7 +98,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
useEffect(
() {
_buildUserProfileImage();
buildUserProfileImage();
return null;
},
[],
@@ -129,12 +129,12 @@ class ProfileDrawerHeader extends HookConsumerWidget {
Stack(
clipBehavior: Clip.none,
children: [
_buildUserProfileImage(),
buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
child: GestureDetector(
onTap: _pickUserProfileImage,
onTap: pickUserProfileImage,
child: Material(
color: Colors.grey[100],
elevation: 3,

View File

@@ -17,7 +17,7 @@ class ServerInfoBox extends HookConsumerWidget {
final appInfo = useState({});
_getPackageInfo() async {
getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
@@ -28,7 +28,7 @@ class ServerInfoBox extends HookConsumerWidget {
useEffect(
() {
_getPackageInfo();
getPackageInfo();
return null;
},
[],

View File

@@ -5,21 +5,25 @@ part 'hive_saved_login_info.model.g.dart';
@HiveType(typeId: 0)
class HiveSavedLoginInfo {
@HiveField(0)
String email;
String email; // DEPRECATED
@HiveField(1)
String password;
String password; // DEPRECATED
@HiveField(2)
String serverUrl;
@HiveField(3)
@HiveField(3, defaultValue: false)
bool isSaveLogin;
@HiveField(4, defaultValue: "")
String accessToken;
HiveSavedLoginInfo({
required this.email,
required this.password,
required this.serverUrl,
required this.isSaveLogin,
required this.accessToken,
});
}

View File

@@ -20,14 +20,15 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
email: fields[0] as String,
password: fields[1] as String,
serverUrl: fields[2] as String,
isSaveLogin: fields[3] as bool,
isSaveLogin: fields[3] == null ? false : fields[3] as bool,
accessToken: fields[4] == null ? '' : fields[4] as String,
);
}
@override
void write(BinaryWriter writer, HiveSavedLoginInfo obj) {
writer
..writeByte(4)
..writeByte(5)
..writeByte(0)
..write(obj.email)
..writeByte(1)
@@ -35,7 +36,9 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
..writeByte(2)
..write(obj.serverUrl)
..writeByte(3)
..write(obj.isSaveLogin);
..write(obj.isSaveLogin)
..writeByte(4)
..write(obj.accessToken);
}
@override

View File

@@ -74,15 +74,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
return false;
}
// Store device id to local storage
var deviceInfo = await _deviceInfoService.getDeviceInfo();
Hive.box(userInfoBox).put(deviceIdKey, deviceInfo["deviceId"]);
state = state.copyWith(
deviceId: deviceInfo["deviceId"],
deviceType: deviceInfo["deviceType"],
);
// Make sign-in request
try {
var loginResponse = await _apiService.authenticationApi.login(
@@ -97,65 +88,16 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
return false;
}
Hive.box(userInfoBox).put(accessTokenKey, loginResponse.accessToken);
state = state.copyWith(
isAuthenticated: true,
userId: loginResponse.userId,
userEmail: loginResponse.userEmail,
firstName: loginResponse.firstName,
lastName: loginResponse.lastName,
profileImagePath: loginResponse.profileImagePath,
isAdmin: loginResponse.isAdmin,
shouldChangePassword: loginResponse.shouldChangePassword,
return setSuccessLoginInfo(
accessToken: loginResponse.accessToken,
serverUrl: serverEndpoint,
isSavedLoginInfo: isSavedLoginInfo,
);
// Login Success - Set Access Token to API Client
_apiService.setAccessToken(loginResponse.accessToken);
if (isSavedLoginInfo) {
// Save login info to local storage
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
HiveSavedLoginInfo(
email: email,
password: password,
isSaveLogin: true,
serverUrl: Hive.box(userInfoBox).get(serverEndpointKey),
),
);
} else {
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.delete(savedLoginInfoKey);
}
} catch (e) {
HapticFeedback.vibrate();
debugPrint("Error logging in $e");
return false;
}
// Register device info
try {
DeviceInfoResponseDto? deviceInfo =
await _apiService.deviceInfoApi.createDeviceInfo(
CreateDeviceInfoDto(
deviceId: state.deviceId,
deviceType: state.deviceType,
),
);
if (deviceInfo == null) {
debugPrint('Device Info Response is null');
return false;
}
state = state.copyWith(deviceInfo: deviceInfo);
} catch (e) {
debugPrint("ERROR Register Device Info: $e");
return false;
}
return true;
}
Future<bool> logout() async {
@@ -215,6 +157,76 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
return false;
}
}
Future<bool> setSuccessLoginInfo({
required String accessToken,
required String serverUrl,
required bool isSavedLoginInfo,
}) async {
_apiService.setAccessToken(accessToken);
var userResponseDto = await _apiService.userApi.getMyUserInfo();
if (userResponseDto != null) {
var userInfoHiveBox = await Hive.openBox(userInfoBox);
var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken);
userInfoHiveBox.put(serverEndpointKey, serverUrl);
state = state.copyWith(
isAuthenticated: true,
userId: userResponseDto.id,
userEmail: userResponseDto.email,
firstName: userResponseDto.firstName,
lastName: userResponseDto.lastName,
profileImagePath: userResponseDto.profileImagePath,
isAdmin: userResponseDto.isAdmin,
shouldChangePassword: userResponseDto.shouldChangePassword,
deviceId: deviceInfo["deviceId"],
deviceType: deviceInfo["deviceType"],
);
if (isSavedLoginInfo) {
// Save login info to local storage
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
HiveSavedLoginInfo(
email: "",
password: "",
isSaveLogin: true,
serverUrl: serverUrl,
accessToken: accessToken,
),
);
} else {
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.delete(savedLoginInfoKey);
}
}
// Register device info
try {
DeviceInfoResponseDto? deviceInfo =
await _apiService.deviceInfoApi.createDeviceInfo(
CreateDeviceInfoDto(
deviceId: state.deviceId,
deviceType: state.deviceType,
),
);
if (deviceInfo == null) {
debugPrint('Device Info Response is null');
return false;
}
state = state.copyWith(deviceInfo: deviceInfo);
} catch (e) {
debugPrint("ERROR Register Device Info: $e");
return false;
}
return true;
}
}
final authenticationProvider =

View File

@@ -0,0 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/services/oauth.service.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
final oAuthServiceProvider =
Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));

View File

@@ -0,0 +1,39 @@
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:flutter_web_auth/flutter_web_auth.dart';
// Redirect URL = app.immich://
class OAuthService {
final ApiService _apiService;
final callbackUrlScheme = 'app.immich';
OAuthService(this._apiService);
Future<OAuthConfigResponseDto?> getOAuthServerConfig(
String serverEndpoint,
) async {
_apiService.setEndpoint(serverEndpoint);
return await _apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'),
);
}
Future<LoginResponseDto?> oAuthLogin(String oauthUrl) async {
try {
var result = await FlutterWebAuth.authenticate(
url: oauthUrl,
callbackUrlScheme: callbackUrlScheme,
);
return await _apiService.oAuthApi.callback(
OAuthCallbackDto(
url: result,
),
);
} catch (e) {
return null;
}
}
}

View File

@@ -6,11 +6,14 @@ 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/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/oauth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
class LoginForm extends HookConsumerWidget {
const LoginForm({Key? key}) : super(key: key);
@@ -23,10 +26,47 @@ class LoginForm extends HookConsumerWidget {
useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController =
useTextEditingController(text: 'login_form_endpoint_hint'.tr());
final apiService = ref.watch(apiServiceProvider);
final serverEndpointFocusNode = useFocusNode();
final isSaveLoginInfo = useState<bool>(false);
final isLoading = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
getServeLoginConfig() async {
if (!serverEndpointFocusNode.hasFocus) {
var urlText = serverEndpointController.text.trim();
try {
var endpointUrl = Uri.tryParse(urlText);
if (endpointUrl != null) {
isLoading.value = true;
apiService.setEndpoint(endpointUrl.toString());
var loginConfig = await apiService.oAuthApi.generateConfig(
OAuthConfigDto(redirectUri: endpointUrl.toString()),
);
if (loginConfig != null) {
isOauthEnable.value = loginConfig.enabled;
oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
} else {
isOauthEnable.value = false;
}
isLoading.value = false;
}
} catch (_) {
isLoading.value = false;
isOauthEnable.value = false;
}
}
}
useEffect(
() {
serverEndpointFocusNode.addListener(getServeLoginConfig);
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
.get(savedLoginInfoKey);
@@ -37,6 +77,7 @@ class LoginForm extends HookConsumerWidget {
isSaveLoginInfo.value = loginInfo.isSaveLogin;
}
getServeLoginConfig();
return null;
},
[],
@@ -67,7 +108,10 @@ class LoginForm extends HookConsumerWidget {
),
EmailInput(controller: usernameController),
PasswordInput(controller: passwordController),
ServerEndpointInput(controller: serverEndpointController),
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
),
CheckboxListTile(
activeColor: Theme.of(context).primaryColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
@@ -92,12 +136,52 @@ class LoginForm extends HookConsumerWidget {
}
},
),
LoginButton(
emailController: usernameController,
passwordController: passwordController,
serverEndpointController: serverEndpointController,
isSavedLoginInfo: isSaveLoginInfo.value,
),
if (isLoading.value)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
if (!isLoading.value)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LoginButton(
emailController: usernameController,
passwordController: passwordController,
serverEndpointController: serverEndpointController,
isSavedLoginInfo: isSaveLoginInfo.value,
),
if (isOauthEnable.value) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Divider(
color: Brightness.dark == Theme.of(context).brightness
? Colors.white
: Colors.black,
),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
isSavedLoginInfo: isSaveLoginInfo.value,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onLoginSuccess: () {
isLoading.value = false;
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).replace(
const TabControllerRoute(),
);
},
),
],
],
)
],
),
),
@@ -108,9 +192,12 @@ class LoginForm extends HookConsumerWidget {
class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller;
const ServerEndpointInput({Key? key, required this.controller})
: super(key: key);
final FocusNode focusNode;
const ServerEndpointInput({
Key? key,
required this.controller,
required this.focusNode,
}) : super(key: key);
String? _validateInput(String? url) {
if (url?.startsWith(RegExp(r'https?://')) == true) {
@@ -131,6 +218,7 @@ class ServerEndpointInput extends StatelessWidget {
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
focusNode: focusNode,
);
}
}
@@ -200,13 +288,9 @@ class LoginButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.grey[50],
elevation: 2,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () async {
// This will remove current cache asset state of previous user login.
@@ -238,10 +322,102 @@ class LoginButton extends ConsumerWidget {
);
}
},
child: const Text(
icon: const Icon(Icons.login_rounded),
label: const Text(
"login_form_button_text",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
);
}
}
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final bool isSavedLoginInfo;
final ValueNotifier<bool> isLoading;
final VoidCallback onLoginSuccess;
final String buttonLabel;
const OAuthLoginButton({
Key? key,
required this.serverEndpointController,
required this.isSavedLoginInfo,
required this.isLoading,
required this.onLoginSuccess,
required this.buttonLabel,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var oAuthService = ref.watch(oAuthServiceProvider);
void performOAuthLogin() async {
ref.watch(assetProvider.notifier).clearAllAsset();
OAuthConfigResponseDto? oAuthServerConfig;
try {
oAuthServerConfig = await oAuthService
.getOAuthServerConfig(serverEndpointController.text);
isLoading.value = true;
} catch (e) {
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_config".tr(),
toastType: ToastType.error,
);
isLoading.value = false;
return;
}
if (oAuthServerConfig != null && oAuthServerConfig.enabled) {
var loginResponseDto =
await oAuthService.oAuthLogin(oAuthServerConfig.url!);
if (loginResponseDto != null) {
var isSuccess = await ref
.watch(authenticationProvider.notifier)
.setSuccessLoginInfo(
accessToken: loginResponseDto.accessToken,
isSavedLoginInfo: isSavedLoginInfo,
serverUrl: serverEndpointController.text,
);
if (isSuccess) {
isLoading.value = false;
onLoginSuccess();
} else {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
);
}
}
isLoading.value = false;
} else {
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_disable".tr(),
toastType: ToastType.info,
);
isLoading.value = false;
return;
}
}
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor.withAlpha(230),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: performOAuthLogin,
icon: const Icon(Icons.pin_rounded),
label: Text(
buttonLabel,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
);
}
}

View File

@@ -39,14 +39,14 @@ class SearchPage extends HookConsumerWidget {
[],
);
_onSearchSubmitted(String searchTerm) async {
onSearchSubmitted(String searchTerm) async {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
}
_buildPlaces() {
buildPlaces() {
return curatedLocation.when(
loading: () => SizedBox(
height: imageSize,
@@ -97,7 +97,7 @@ class SearchPage extends HookConsumerWidget {
);
}
_buildThings() {
buildThings() {
return curatedObjects.when(
loading: () => SizedBox(
height: imageSize,
@@ -155,7 +155,7 @@ class SearchPage extends HookConsumerWidget {
return Scaffold(
appBar: SearchBar(
searchFocusNode: searchFocusNode,
onSubmitted: _onSearchSubmitted,
onSubmitted: onSearchSubmitted,
),
body: GestureDetector(
onTap: () {
@@ -174,7 +174,7 @@ class SearchPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
_buildPlaces(),
buildPlaces(),
Padding(
padding: const EdgeInsets.all(16.0),
child: const Text(
@@ -182,11 +182,11 @@ class SearchPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
_buildThings()
buildThings()
],
),
if (isSearchEnabled)
SearchSuggestionList(onSubmitted: _onSearchSubmitted),
SearchSuggestionList(onSubmitted: onSearchSubmitted),
],
),
),

View File

@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.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/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
@@ -10,6 +9,7 @@ import 'package:immich_mobile/modules/search/providers/search_result_page.provid
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class SearchResultPage extends HookConsumerWidget {
const SearchResultPage({Key? key, required this.searchTerm})
@@ -38,7 +38,7 @@ class SearchResultPage extends HookConsumerWidget {
[],
);
_onSearchSubmitted(String newSearchTerm) {
onSearchSubmitted(String newSearchTerm) {
debugPrint("Re-Search with $newSearchTerm");
searchFocusNode?.unfocus();
isNewSearch.value = false;
@@ -46,7 +46,7 @@ class SearchResultPage extends HookConsumerWidget {
ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
}
_buildTextField() {
buildTextField() {
return TextField(
controller: searchTermController,
focusNode: searchFocusNode,
@@ -60,7 +60,7 @@ class SearchResultPage extends HookConsumerWidget {
onSubmitted: (searchTerm) {
if (searchTerm.isNotEmpty) {
searchTermController.clear();
_onSearchSubmitted(searchTerm);
onSearchSubmitted(searchTerm);
} else {
isNewSearch.value = false;
}
@@ -80,7 +80,7 @@ class SearchResultPage extends HookConsumerWidget {
);
}
_buildChip() {
buildChip() {
return Chip(
label: Wrap(
spacing: 5,
@@ -108,7 +108,7 @@ class SearchResultPage extends HookConsumerWidget {
);
}
_buildSearchResult() {
buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageProvider);
var searchResultRenderList = ref.watch(searchRenderListProvider);
@@ -122,11 +122,7 @@ class SearchResultPage extends HookConsumerWidget {
}
if (searchResultPageState.isLoading) {
return Center(
child: SpinKitDancingSquare(
color: Theme.of(context).primaryColor,
),
);
return const Center(child: ImmichLoadingIndicator());
}
if (searchResultPageState.isSuccess) {
@@ -158,7 +154,7 @@ class SearchResultPage extends HookConsumerWidget {
isNewSearch.value = true;
searchFocusNode?.requestFocus();
},
child: isNewSearch.value ? _buildTextField() : _buildChip(),
child: isNewSearch.value ? buildTextField() : buildChip(),
),
centerTitle: false,
),
@@ -172,9 +168,9 @@ class SearchResultPage extends HookConsumerWidget {
},
child: Stack(
children: [
_buildSearchResult(),
buildSearchResult(),
if (isNewSearch.value)
SearchSuggestionList(onSubmitted: _onSearchSubmitted),
SearchSuggestionList(onSubmitted: onSearchSubmitted),
],
),
),

View File

@@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';

View File

@@ -65,7 +65,11 @@ class _$AppRouter extends RootStackRouter {
final args = routeData.argsAs<VideoViewerRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: VideoViewerPage(key: args.key, asset: args.asset));
child: VideoViewerPage(
key: args.key,
asset: args.asset,
isMotionVideo: args.isMotionVideo,
onVideoEnded: args.onVideoEnded));
},
BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
@@ -340,24 +344,40 @@ class ImageViewerRouteArgs {
/// generated route for
/// [VideoViewerPage]
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
VideoViewerRoute({Key? key, required Asset asset})
VideoViewerRoute(
{Key? key,
required Asset asset,
required bool isMotionVideo,
required void Function() onVideoEnded})
: super(VideoViewerRoute.name,
path: '/video-viewer-page',
args: VideoViewerRouteArgs(key: key, asset: asset));
args: VideoViewerRouteArgs(
key: key,
asset: asset,
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded));
static const String name = 'VideoViewerRoute';
}
class VideoViewerRouteArgs {
const VideoViewerRouteArgs({this.key, required this.asset});
const VideoViewerRouteArgs(
{this.key,
required this.asset,
required this.isMotionVideo,
required this.onVideoEnded});
final Key? key;
final Asset asset;
final bool isMotionVideo;
final void Function() onVideoEnded;
@override
String toString() {
return 'VideoViewerRouteArgs{key: $key, asset: $asset}';
return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded}';
}
}

View File

@@ -39,7 +39,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
@@ -145,7 +146,9 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
ref.watch(assetServiceProvider),
ref.watch(assetCacheServiceProvider),
);
});
final assetGroupByDateTimeProvider = StateProvider((ref) {

View File

@@ -1,7 +1,9 @@
import 'package:dio/dio.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
@@ -9,21 +11,21 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
ReleaseInfoNotifier() : super("");
void checkGithubReleaseInfo() async {
var dio = Dio();
final Client client = Client();
var box = Hive.box(hiveGithubReleaseInfoBox);
try {
String? localReleaseVersion = box.get(githubReleaseInfoKey);
var res = await dio.get(
"https://api.github.com/repos/alextran1502/immich/releases/latest",
options: Options(
headers: {"Accept": "application/vnd.github.v3+json"},
final res = await client.get(
Uri.parse(
"https://api.github.com/repos/immich-app/immich/releases/latest",
),
headers: {"Accept": "application/vnd.github.v3+json"},
);
if (res.statusCode == 200) {
String latestTagVersion = res.data["tag_name"];
final data = jsonDecode(res.body);
String latestTagVersion = data["tag_name"];
state = latestTagVersion;
debugPrint("Local release version $localReleaseVersion");

View File

@@ -5,6 +5,7 @@ class ApiService {
late UserApi userApi;
late AuthenticationApi authenticationApi;
late OAuthApi oAuthApi;
late AlbumApi albumApi;
late AssetApi assetApi;
late ServerInfoApi serverInfoApi;
@@ -14,6 +15,7 @@ class ApiService {
_apiClient = ApiClient(basePath: endpoint);
userApi = UserApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = OAuthApi(_apiClient);
albumApi = AlbumApi(_apiClient);
assetApi = AssetApi(_apiClient);
serverInfoApi = ServerInfoApi(_apiClient);

View File

@@ -1,3 +1,4 @@
// ignore: depend_on_referenced_packages
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';

View File

@@ -1,140 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.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/utils/dio_http_interceptor.dart';
final networkServiceProvider = Provider((_) => NetworkService());
class NetworkService {
late final Dio dio;
NetworkService() {
dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
}
Future<dynamic> deleteRequest({required String url, dynamic data}) async {
try {
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Response res = await dio.delete('$savedEndpoint/$url', data: data);
if (res.statusCode == 200) {
return res;
}
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
} catch (e) {
debugPrint("ERROR deleteRequest: ${e.toString()}");
}
}
Future<dynamic> getRequest({
required String url,
bool isByteResponse = false,
bool isStreamReponse = false,
}) async {
try {
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
if (isByteResponse) {
Response<List<int>> res = await dio.get<List<int>>(
'$savedEndpoint/$url',
options: Options(responseType: ResponseType.bytes),
);
if (res.statusCode == 200) {
return res;
}
} else if (isStreamReponse) {
Response<ResponseBody> res = await dio.get<ResponseBody>(
'$savedEndpoint/$url',
options: Options(responseType: ResponseType.stream),
);
if (res.statusCode == 200) {
return res;
}
} else {
Response res = await dio.get('$savedEndpoint/$url');
if (res.statusCode == 200) {
return res;
}
}
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
} catch (e) {
debugPrint("ERROR getRequest: ${e.toString()}");
}
}
Future<dynamic> postRequest({required String url, dynamic data}) async {
try {
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
var res = await dio.post(validUrl, data: data);
return res;
} on DioError catch (e) {
debugPrint("[postRequest] DioError: ${e.response}");
return null;
} catch (e) {
debugPrint("ERROR PostRequest: $e");
return null;
}
}
Future<dynamic> putRequest({required String url, dynamic data}) async {
try {
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
var res = await dio.put(validUrl, data: data);
return res;
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
return null;
} catch (e) {
debugPrint("ERROR PutRequest: $e");
return null;
}
}
Future<dynamic> patchRequest({required String url, dynamic data}) async {
try {
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var validUrl = Uri.parse('$savedEndpoint/$url').toString();
var res = await dio.patch(validUrl, data: data);
return res;
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
} catch (e) {
debugPrint("ERROR PatchRequest: $e");
}
}
Future<bool> pingServer() async {
try {
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
var validUrl = Uri.parse('$savedEndpoint/server-info/ping').toString();
debugPrint("ping server at url $validUrl");
var res = await dio.get(validUrl);
var jsonRespsonse = jsonDecode(res.toString());
return jsonRespsonse["res"] == "pong";
} on DioError catch (e) {
debugPrint("[PING SERVER] DioError: ${e.response} - $e");
return false;
} catch (e) {
debugPrint("ERROR PingServer: $e");
return false;
}
}
}

View File

@@ -28,8 +28,7 @@ class ShareService {
final fileName = basename(asset.remote!.originalPath);
final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.remote!.deviceAssetId,
asset.remote!.deviceId,
asset.remote!.id,
isThumb: false,
isWeb: false,
);

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
class ImmichLoadingIndicator extends StatelessWidget {
const ImmichLoadingIndicator({
@@ -15,10 +14,8 @@ class ImmichLoadingIndicator extends StatelessWidget {
color: Theme.of(context).primaryColor.withAlpha(200),
borderRadius: BorderRadius.circular(10),
),
child: const SpinKitDancingSquare(
color: Colors.white,
size: 30.0,
),
padding: const EdgeInsets.all(15),
child: const CircularProgressIndicator(color: Colors.white),
);
}
}

View File

@@ -9,12 +9,13 @@ class ImmichToast {
required String msg,
ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.TOP,
int durationInSecond = 3,
}) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final fToast = FToast();
fToast.init(context);
Color _getColor(ToastType type, BuildContext context) {
Color getColor(ToastType type, BuildContext context) {
switch (type) {
case ToastType.info:
return Theme.of(context).primaryColor;
@@ -25,7 +26,7 @@ class ImmichToast {
}
}
Icon _getIcon(ToastType type) {
Icon getIcon(ToastType type) {
switch (type) {
case ToastType.info:
return Icon(
@@ -59,7 +60,7 @@ class ImmichToast {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_getIcon(toastType),
getIcon(toastType),
const SizedBox(
width: 12.0,
),
@@ -67,7 +68,7 @@ class ImmichToast {
child: Text(
msg,
style: TextStyle(
color: _getColor(toastType, context),
color: getColor(toastType, context),
fontWeight: FontWeight.bold,
fontSize: 15,
),
@@ -77,7 +78,7 @@ class ImmichToast {
),
),
gravity: gravity,
toastDuration: const Duration(seconds: 2),
toastDuration: Duration(seconds: durationInSecond),
);
}
}

View File

@@ -8,29 +8,39 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
class SplashScreenPage extends HookConsumerWidget {
const SplashScreenPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiService = ref.watch(apiServiceProvider);
HiveSavedLoginInfo? loginInfo =
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
void performLoggingIn() async {
var isAuthenticated =
await ref.read(authenticationProvider.notifier).login(
loginInfo!.email,
loginInfo.password,
loginInfo.serverUrl,
true,
);
try {
if (loginInfo != null) {
// Make sure API service is initialized
apiService.setEndpoint(loginInfo.serverUrl);
if (isAuthenticated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
var isSuccess = await ref
.read(authenticationProvider.notifier)
.setSuccessLoginInfo(
accessToken: loginInfo.accessToken,
isSavedLoginInfo: true,
serverUrl: loginInfo.serverUrl,
);
if (isSuccess) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
AutoRouter.of(context).replace(const LoginRoute());
}
}
} catch (_) {
AutoRouter.of(context).replace(const LoginRoute());
}
}

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