Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
126f5857c3 | ||
|
|
8b3e1764a8 | ||
|
|
b776461297 | ||
|
|
4a0052026f | ||
|
|
35c4887e4a | ||
|
|
f5b87833f8 | ||
|
|
0dde76bbbc | ||
|
|
93863b0629 | ||
|
|
115a47d4c6 | ||
|
|
308c63df16 | ||
|
|
ab86d0a18d | ||
|
|
3ec74444b0 | ||
|
|
1979c84ea8 | ||
|
|
f4aefcb18b | ||
|
|
7f2fa23179 | ||
|
|
4524aa0d06 | ||
|
|
15fa8250cb | ||
|
|
4dff129949 | ||
|
|
43951ec208 | ||
|
|
f961acdf0c | ||
|
|
2c7821e5e6 | ||
|
|
d25ddfc46b | ||
|
|
8cc9b08c06 | ||
|
|
98b9d815a6 | ||
|
|
a808b9403e | ||
|
|
c956eee919 | ||
|
|
aa97ca9ccf | ||
|
|
98bb3de8da | ||
|
|
cd43edf074 | ||
|
|
dffd992304 | ||
|
|
f1b70e13a1 | ||
|
|
d91247dc35 | ||
|
|
25f55ee6bb | ||
|
|
c2e9fe0aac | ||
|
|
5885ec8e65 | ||
|
|
053104fc50 | ||
|
|
861de7f8b3 | ||
|
|
b5a4aef829 | ||
|
|
54b4f8afbd | ||
|
|
65daf342df | ||
|
|
15a498fd60 | ||
|
|
af7da9d2c9 | ||
|
|
91ad584064 | ||
|
|
b6b9f51bd7 | ||
|
|
6acfb55dcc | ||
|
|
ce42b84430 | ||
|
|
59d93138d3 | ||
|
|
78de189d56 | ||
|
|
b21c99eb12 | ||
|
|
e22cdea485 | ||
|
|
1e97407025 | ||
|
|
c4f5dc6d01 | ||
|
|
c329a17975 | ||
|
|
2a88cc74bf | ||
|
|
7e965cb6d4 | ||
|
|
6631b286c1 | ||
|
|
b8313abfa8 | ||
|
|
aa91b946fa | ||
|
|
82af2c5717 | ||
|
|
4cdc59e51c | ||
|
|
d34585e4b0 | ||
|
|
d565a684a1 | ||
|
|
13f178dca8 | ||
|
|
f8ba33e81c | ||
|
|
3d251f51fc | ||
|
|
57704522cd | ||
|
|
787926c111 | ||
|
|
08b424b3df | ||
|
|
736a946101 | ||
|
|
6f6f847ee2 | ||
|
|
13be271df7 | ||
|
|
b423852fad | ||
|
|
fe3d6b870a | ||
|
|
14be63039f | ||
|
|
d339d4c8dd |
2
.github/workflows/build-mobile.yml
vendored
2
.github/workflows/build-mobile.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.7.3"
|
||||
flutter-version: "3.10.0"
|
||||
cache: true
|
||||
|
||||
- name: Create the Keystore
|
||||
|
||||
79
.github/workflows/docker-cleanup.yml
vendored
Normal file
79
.github/workflows/docker-cleanup.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
# This workflow runs on certain conditions to check for and potentially
|
||||
# delete container images from the GHCR which no longer have an associated
|
||||
# code branch.
|
||||
# Requires a PAT with the correct scope set in the secrets.
|
||||
#
|
||||
# This workflow will not trigger runs on forked repos.
|
||||
|
||||
name: Cleanup Old Docker Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- "closed"
|
||||
push:
|
||||
paths:
|
||||
- ".github/workflows/docker-cleanup.yml"
|
||||
|
||||
concurrency:
|
||||
group: registry-tags-cleanup
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cleanup-images:
|
||||
name: Cleanup Stale Images Tags for ${{ matrix.primary-name }}
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- primary-name: "immich-server"
|
||||
- primary-name: "immich-machine-learning"
|
||||
- primary-name: "immich-web"
|
||||
- primary-name: "immich-proxy"
|
||||
env:
|
||||
# Requires a personal access token with the OAuth scope delete:packages
|
||||
TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }}
|
||||
steps:
|
||||
-
|
||||
name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.1.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "immich-app"
|
||||
is_org: "true"
|
||||
do_delete: "true"
|
||||
package_name: "${{ matrix.primary-name }}"
|
||||
scheme: "pull_request"
|
||||
repo_name: "immich"
|
||||
match_regex: '^pr-(\d+)$|^(\d+)$'
|
||||
|
||||
cleanup-untagged-images:
|
||||
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- cleanup-images
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- primary-name: "immich-server"
|
||||
- primary-name: "immich-machine-learning"
|
||||
- primary-name: "immich-web"
|
||||
- primary-name: "immich-proxy"
|
||||
- primary-name: "immich-build-cache"
|
||||
env:
|
||||
# Requires a personal access token with the OAuth scope delete:packages
|
||||
TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }}
|
||||
steps:
|
||||
-
|
||||
name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.1.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "immich-app"
|
||||
do_delete: "true"
|
||||
is_org: "true"
|
||||
package_name: "${{ matrix.primary-name }}"
|
||||
5
.github/workflows/static_analysis.yml
vendored
5
.github/workflows/static_analysis.yml
vendored
@@ -22,8 +22,8 @@ jobs:
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: '3.7.3'
|
||||
channel: "stable"
|
||||
flutter-version: "3.10.0"
|
||||
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
@@ -32,4 +32,3 @@ jobs:
|
||||
- name: Run dart analyze
|
||||
run: dart analyze --fatal-infos
|
||||
working-directory: ./mobile
|
||||
|
||||
|
||||
40
.github/workflows/test.yml
vendored
40
.github/workflows/test.yml
vendored
@@ -21,6 +21,28 @@ jobs:
|
||||
- name: Run Immich Server E2E Test
|
||||
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
||||
|
||||
doc-tests:
|
||||
name: Run documentation checks
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./docs
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run tsc
|
||||
run: npm run check
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
server-unit-tests:
|
||||
name: Run server unit test suites and checks
|
||||
runs-on: ubuntu-latest
|
||||
@@ -90,10 +112,10 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.7.3"
|
||||
flutter-version: "3.10.0"
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test
|
||||
run: flutter test -j 1
|
||||
|
||||
generated-api-up-to-date:
|
||||
name: Check generated files are up-to-date
|
||||
@@ -101,7 +123,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run API generation
|
||||
run: cd server && npm ci && npm run api:generate
|
||||
run: npm --prefix server run api:generate
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@v13.1
|
||||
id: verify-changed-files
|
||||
@@ -136,18 +158,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install server dependencies
|
||||
run: |
|
||||
cd server
|
||||
npm ci
|
||||
run: npm --prefix server ci
|
||||
- name: Run existing migrations
|
||||
run: |
|
||||
cd server
|
||||
npm run typeorm:migrations:run
|
||||
run: npm --prefix server run typeorm:migrations:run
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd server
|
||||
npm run typeorm:migrations:generate ./libs/infra/src/migrations/TestMigration
|
||||
run: npm --prefix server run typeorm:migrations:generate ./libs/infra/src/migrations/TestMigration
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@v13.1
|
||||
id: verify-changed-files
|
||||
|
||||
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
@@ -9,6 +9,15 @@
|
||||
"name": "Immich Server",
|
||||
"remoteRoot": "/usr/src/app",
|
||||
"localRoot": "${workspaceFolder}/server"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"restart": true,
|
||||
"port": 9231,
|
||||
"name": "Immich Microservices",
|
||||
"remoteRoot": "/usr/src/app",
|
||||
"localRoot": "${workspaceFolder}/server"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
11
README.md
11
README.md
@@ -37,7 +37,6 @@
|
||||
- [Installation](https://immich.app/docs/install/requirements)
|
||||
- [Contribution Guidelines](https://immich.app/docs/overview/support-the-project)
|
||||
- [Support The Project](#support-the-project)
|
||||
- [Known Issues](#known-issues)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -72,7 +71,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
| Scrubbable/draggable scrollbar | Yes | Yes |
|
||||
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes |
|
||||
| Metadata view (EXIF, map) | Yes | Yes |
|
||||
| Search by metadata, objects and CLIP | Yes | No |
|
||||
| Search by metadata, objects and CLIP | Yes | Yes |
|
||||
| Administrative functions (user management) | N/A | Yes |
|
||||
| Background backup | Yes | N/A |
|
||||
| Virtual scroll | Yes | Yes |
|
||||
@@ -96,11 +95,3 @@ If you feel like this is the right cause and the app is something you are seeing
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
|
||||
# Known Issues
|
||||
|
||||
## immich-machine-learning fails to start
|
||||
|
||||
Symptoms: the container logs `illegal instruction core dump` and restarts
|
||||
|
||||
Solution: https://immich.app/docs/install/requirements#hardware
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -53,13 +53,15 @@ services:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
command: npm run start:dev microservices
|
||||
command: npm run start:debug microservices
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 9231:9230
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# Database
|
||||
###################################################################################
|
||||
|
||||
# NOTE: The following four database variables support Docker secrets by adding a *_FILE suffix to the variable name
|
||||
# See the docker-compose documentation on secrets for additional details: https://docs.docker.com/compose/compose-file/compose-file-v3/#secrets
|
||||
DB_HOSTNAME=immich_postgres
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
2
docs/.prettierignore
Normal file
2
docs/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
.docusaurus/
|
||||
6
docs/.prettierrc
Normal file
6
docs/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"semi": true
|
||||
}
|
||||
@@ -33,9 +33,8 @@ The motion part will now be uploaded and can be played on the mobile app and the
|
||||
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",
|
||||
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"
|
||||
/>
|
||||
@@ -73,9 +72,8 @@ The web will have the option to sign in with OAuth.
|
||||
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",
|
||||
borderRadius: '10px',
|
||||
boxShadow: 'rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -86,9 +84,8 @@ sign-in button.
|
||||
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",
|
||||
borderRadius: '10px',
|
||||
boxShadow: 'rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -98,9 +95,8 @@ sign-in button.
|
||||
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",
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -22,11 +22,11 @@ The initial approach of Immich is to become a backup tool, primarily for mobile
|
||||
|
||||
### Why does my uploaded photo show up with the wrong date or time in Immich?
|
||||
|
||||
When a photo is initially uploaded Immich uses the create date of the file to determine where it belongs in the timeline. After that, background jobs will run that extract [exif metadata](https://en.wikipedia.org/wiki/Exif), including the CreateDate, to provide a more accurate date for the photo. If that is not available it will fallback to the modified date. If you want to ensure your photo has the right date, check the exif metadata before uploading.
|
||||
When a photo is initially uploaded Immich uses the create date of the file to determine where it belongs in the timeline. After that, background jobs will run that extract [exif metadata](https://en.wikipedia.org/wiki/Exif), including the CreateDate, to provide a more accurate date for the photo. If that is not available it will fallback to the modified date. If you want to ensure your photo has the right date, check the exif metadata before uploading.
|
||||
|
||||
If the timezone is incorrect in an uploaded photo, check the ``DateTimeOriginal`` exif field of the uploaded file. Immich uses the very competent library [exiftool-vendored.js](https://github.com/photostructure/exiftool-vendored.js#dates) to handle timezone parsing, but in some cases (like photos taken with DSLR cameras) it has to fallback on the local timezone. If you are using docker, this fallback will be UTC. (Note that even the photo backup app that can't be named [has the same bug!](https://photo.stackexchange.com/a/126978)) In Immich, it is possible to change this assumed fallback timezone system-wide by setting the timezone in the microservices docker container. You might need to run the "Extract Metadata" job after to effect the change.
|
||||
If the timezone is incorrect in an uploaded photo, check the `DateTimeOriginal` exif field of the uploaded file. Immich uses the very competent library [exiftool-vendored.js](https://github.com/photostructure/exiftool-vendored.js#dates) to handle timezone parsing, but in some cases (like photos taken with DSLR cameras) it has to fallback on the local timezone. If you are using docker, this fallback will be UTC. (Note that even the photo backup app that can't be named [has the same bug!](https://photo.stackexchange.com/a/126978)) In Immich, it is possible to change this assumed fallback timezone system-wide by setting the timezone in the microservices docker container. You might need to run the "Extract Metadata" job after to effect the change.
|
||||
|
||||
As an example, the following modification of ```docker-compose.yml``` will set the timezone of the microservices container to be ``Europe/Stockholm``
|
||||
As an example, the following modification of `docker-compose.yml` will set the timezone of the microservices container to be `Europe/Stockholm`
|
||||
|
||||
```
|
||||
environment:
|
||||
@@ -34,16 +34,27 @@ As an example, the following modification of ```docker-compose.yml``` will set t
|
||||
```
|
||||
|
||||
### Why are only photos and not videos being uploaded to Immich?
|
||||
|
||||
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes.
|
||||
|
||||
### Why is Immich slow on low-memory systems like the Raspberry Pi?
|
||||
|
||||
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
|
||||
|
||||
### How to disable machine-learning and TypeSense?
|
||||
|
||||
:::warning
|
||||
Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning.
|
||||
:::
|
||||
|
||||
These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_URL=false` & `TYPESENSE_ENABLED=false` in your .env file.
|
||||
|
||||
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
|
||||
|
||||
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
|
||||
|
||||
### In the uploads folder, why are photos stored in the wrong date?
|
||||
|
||||
This is fixed by running the storage migration job.
|
||||
|
||||
### Why is object detection not very good?
|
||||
@@ -66,6 +77,10 @@ The non-root user/group needs read/write access to the volume mounts, including
|
||||
|
||||
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
|
||||
|
||||
### How can I backup data from Immich?
|
||||
|
||||
See [backup and restore](/docs/administration/backup-and-restore.md).
|
||||
|
||||
### How can I **purge** data from Immich?
|
||||
|
||||
Data for Immich comes in two forms:
|
||||
@@ -80,3 +95,7 @@ docker-compose down -v
|
||||
```
|
||||
|
||||
After removing the the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting an unwanted files or folders.
|
||||
|
||||
### Why iOS app shows duplicate photos on the timeline while the web doesn't?
|
||||
|
||||
If you are using `My Photo Stream`, the Photos app temporarily creates duplicates of photos taken in the last 30 days. These photos are included in the `Recents` album and thus shown up twice. To fix this, you can disable `My Photo Stream` in the native Photos app or choose a different album in the backup screen in Immich.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"label": "Administration",
|
||||
"position": 4
|
||||
}
|
||||
|
||||
"label": "Administration",
|
||||
"position": 4
|
||||
}
|
||||
|
||||
55
docs/docs/administration/backup-and-restore.md
Normal file
55
docs/docs/administration/backup-and-restore.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Backup and Restore
|
||||
|
||||
## Database
|
||||
|
||||
:::info
|
||||
Refer to the official [postgres documentation](https://www.postgresql.org/docs/current/backup.html) for details about backing up and restoring a postgres database.
|
||||
:::
|
||||
|
||||
The recommended way to backup and restore the Immich database is to use the `pg_dumpall` command.
|
||||
|
||||
```bash title='Backup'
|
||||
docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/backup/dump.sql.gz"
|
||||
```
|
||||
|
||||
```bash title='Restore'
|
||||
gunzip < /path/to/backup/dump.sql.gz | docker exec -i immich_postgres psql -U postgres -d immich
|
||||
```
|
||||
|
||||
The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
...
|
||||
backup:
|
||||
container_name: immich_db_dumper
|
||||
image: prodrigestivill/postgres-backup-local
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_HOST: database
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
SCHEDULE: "@daily"
|
||||
BACKUP_NUM_KEEP: 7
|
||||
BACKUP_DIR: /db_dumps
|
||||
volumes:
|
||||
- ./db_dumps:/db_dumps
|
||||
depends_on:
|
||||
- database
|
||||
```
|
||||
|
||||
Then you can restore with the same command but pointed at the latest dump.
|
||||
|
||||
```bash title='Automated Restore'
|
||||
gunzip < db_dumps/last/immich-latest.sql.gz | docker exec -i immich_postgres psql -U postgres -d immich
|
||||
```
|
||||
|
||||
## Filesystem
|
||||
|
||||
Immich stores two types of content in the filesystem: (1) original, unmodified content, and (2) generated content. Only the original content needs to be backed-up, which includes the following folders:
|
||||
|
||||
1. `UPLOAD_LOCATION/library`
|
||||
1. `UPLOAD_LOCATION/upload`
|
||||
1. `UPLOAD_LOCATION/profile`
|
||||
@@ -36,7 +36,6 @@ Immich is a full-stack [TypeScript](https://www.typescriptlang.org/) application
|
||||
- [Redis](https://redis.io/) for job queuing.
|
||||
- [Typesense](https://typesense.org/) for search.
|
||||
|
||||
|
||||
### Web Server
|
||||
|
||||
- [NGINX](https://www.nginx.com/) for internal communication between containers and load balancing when scaling.
|
||||
|
||||
@@ -14,17 +14,19 @@ Background backup is available thanks to the contribution effort of [@zoodyy](ht
|
||||
|
||||
If background backup is enabled. The app will periodically check if there are any new photos or videos in the selected album(s) to be uploaded to the cloud. If there are, it will upload them to the cloud in the background.
|
||||
|
||||
|
||||
:::info Note
|
||||
|
||||
#### General
|
||||
|
||||
- The app must be in the background for the backup worker to start running.
|
||||
- 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.
|
||||
|
||||
#### Android
|
||||
|
||||
- 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.
|
||||
|
||||
#### iOS
|
||||
|
||||
- You must enable **Background App Refresh** for the app to work in the background. You can enable it in the Settings app under General > Background App Refresh.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
|
||||
@@ -49,7 +49,6 @@ The API key can be obtained in the user setting panel on the web interface.
|
||||
|
||||

|
||||
|
||||
|
||||
### Run via Docker
|
||||
|
||||
You can run the CLI inside of a docker container to avoid needing to install anything.
|
||||
@@ -74,6 +73,7 @@ immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
|
||||
|
||||
:::tip Internal networking
|
||||
If you are running the CLI container on the same machine as your Immich server, you may not be able to reach the external address. In that case, try the following steps:
|
||||
|
||||
1. Find the internal Docker network used by Immich via `docker network ls`.
|
||||
2. Adapt the above command to pass the `--network <immich_network>` argument to `docker run`, substituting `<immich_network>` with the result from step 1.
|
||||
3. Use `--server http://immich-server:3001/` for the upload command instead of the external address.
|
||||
@@ -81,6 +81,7 @@ If you are running the CLI container on the same machine as your Immich server,
|
||||
```bash title="Upload to internal address"
|
||||
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://immich-server:3001/
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### Run from source
|
||||
|
||||
@@ -4,7 +4,7 @@ Immich supports [Reverse Geocoding](https://en.wikipedia.org/wiki/Reverse_geocod
|
||||
|
||||
## Extraction
|
||||
|
||||
During Exif Extraction, assets with latitudes and longitudes are reverse geocoded to determine their City, State, and Country.
|
||||
During Exif Extraction, assets with latitudes and longitudes are reverse geocoded to determine their City, State, and Country.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Search
|
||||
|
||||
Immich uses Typesense as the primary search database to enable high performance search mechanism.
|
||||
Immich uses Typesense as the primary search database to enable high performance search mechanism.
|
||||
|
||||
Typesense is a powerful search engine that can be integrated with popular natural language processing (NLP) models like CLIP and SBERT to provide highly accurate and relevant search results. Here are some benefits of using Typesense integrated search for CLIP and SBERT:
|
||||
|
||||
@@ -10,11 +10,11 @@ Faster Search Response Times: Typesense is optimized for lightning-fast search r
|
||||
|
||||
Enhanced Semantic Search Capabilities: CLIP and SBERT are powerful NLP models that can extract the semantic meaning from text, enabling more nuanced search queries. By integrating with Typesense, these models can help to improve the accuracy of semantic search, enabling users to find the most relevant results based on the true meaning of their query.
|
||||
|
||||
Greater Search Flexibility: Typesense provides flexible search capabilities, including fuzzy search, partial search, enabling users to find the information they need quickly and easily. When integrated with CLIP and SBERT, Typesense can offer even greater flexibility, allowing users to refine their search queries using natural language and providing more accurate and relevant results.
|
||||
Greater Search Flexibility: Typesense provides flexible search capabilities, including fuzzy search, partial search, enabling users to find the information they need quickly and easily. When integrated with CLIP and SBERT, Typesense can offer even greater flexibility, allowing users to refine their search queries using natural language and providing more accurate and relevant results.
|
||||
|
||||
(Generated by Chat-GPT4)
|
||||
|
||||
Some search examples:
|
||||
<img src={require('./img/search-ex-2.webp').default} title='Search Example 1' />
|
||||
Some search examples:
|
||||
<img src={require('./img/search-ex-2.webp').default} title='Search Example 1' />
|
||||
|
||||
<img src={require('./img/search-ex-3.webp').default} title='Search Example 2' />
|
||||
|
||||
@@ -10,7 +10,7 @@ View your User ID and email, and update your first and last name.
|
||||
|
||||
## Change Password
|
||||
|
||||
Users can change their own passwords.
|
||||
Users can change their own passwords.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -13,37 +13,36 @@ Install Immich using Portainer's Stack feature.
|
||||
5. Replace `.env` with `stack.env` for all containers that need to use environment variables in the web editor.
|
||||
|
||||
<img
|
||||
src={require('./img/dot-env.png').default}
|
||||
width="50%"
|
||||
style={{border: '1px solid #ddd'}}
|
||||
alt="Dot Env Example"
|
||||
src={require('./img/dot-env.png').default}
|
||||
width="50%"
|
||||
style={{border: '1px solid #ddd'}}
|
||||
alt="Dot Env Example"
|
||||
/>
|
||||
|
||||
8. Click on "**Advanced Mode**" in the **Environment Variables** section.
|
||||
|
||||
<img
|
||||
src={require('./img/env-1.png').default}
|
||||
width="50%"
|
||||
style={{border: '1px solid #ddd'}}
|
||||
alt="Dot Env Example"
|
||||
src={require('./img/env-1.png').default}
|
||||
width="50%"
|
||||
style={{border: '1px solid #ddd'}}
|
||||
alt="Dot Env Example"
|
||||
/>
|
||||
|
||||
9. Copy the content of the `example.env` file from the [GitHub repository](https://github.com/immich-app/immich/releases/latest/download/example.env) and paste into the editor.
|
||||
10. Switch back to "**Simple Mode**".
|
||||
|
||||
<img
|
||||
src={require('./img/env-2.png').default}
|
||||
width="50%"
|
||||
style={{border: '1px solid #ddd'}}
|
||||
alt="Dot Env Example"
|
||||
src={require('./img/env-2.png').default}
|
||||
width="50%"
|
||||
style={{border: '1px solid #ddd'}}
|
||||
alt="Dot Env Example"
|
||||
/>
|
||||
|
||||
* Populate custom database information if necessary.
|
||||
* Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
|
||||
- Populate custom database information if necessary.
|
||||
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
|
||||
|
||||
11. Click on "**Deploy the stack**".
|
||||
|
||||
|
||||
:::tip
|
||||
For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide.
|
||||
:::
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
|
||||
# Requirements
|
||||
|
||||
Hardware and software requirements for Immich
|
||||
|
||||
## Software
|
||||
|
||||
@@ -5,6 +5,7 @@ sidebar_position: 60
|
||||
# Unraid
|
||||
|
||||
Immich can easily be installed and updated on Unraid via:
|
||||
|
||||
1. [Docker Compose Manager](https://forums.unraid.net/topic/114415-plugin-docker-compose-manager/) plugin from the Unraid Community Apps
|
||||
2. Community made template on the Unraid Community Apps
|
||||
|
||||
@@ -20,7 +21,7 @@ In order to install Immich from the Unraid CA, you will need an existing Redis a
|
||||
|
||||
Once you have Redis and PostgreSQL running, search for Immich on the Unraid CA, choose either of the templates listed and fill out the example variables.
|
||||
|
||||
For more information about setting up the community image see [here](https://github.com/imagegenius/docker-immich#application-setup)
|
||||
For more information about setting up the community image see [here](https://github.com/imagegenius/docker-immich#application-setup)
|
||||
|
||||
## Docker-Compose Method (Official)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 1
|
||||
|
||||
# Introduction
|
||||
|
||||
<img src={require('./img/feature-panel.png').default} alt='Immich' />
|
||||
<img src={require('./img/feature-panel.png').default} alt="Immich" />
|
||||
|
||||
## Welcome!
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ If you feel like this is the right cause and the app is something you see yourse
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
There are lots of non-monetary ways to contribute to Immich as well.
|
||||
|
||||
@@ -7,8 +7,7 @@ const darkCodeTheme = require('prism-react-renderer/themes/dracula');
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: 'Immich',
|
||||
tagline:
|
||||
'High performance self-hosted photo and video backup solution directly from your mobile phone',
|
||||
tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
|
||||
url: 'https://documentation.immich.app',
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'throw',
|
||||
@@ -111,11 +110,6 @@ const config = {
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/orgs/immich-app/projects/1',
|
||||
label: 'Roadmap',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
@@ -154,10 +148,6 @@ const config = {
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/immich-app/immich',
|
||||
},
|
||||
{
|
||||
label: 'Roadmap',
|
||||
href: 'https://github.com/orgs/immich-app/projects/1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
22
docs/package-lock.json
generated
22
docs/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.1.0",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -10538,6 +10539,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
||||
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-error": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
|
||||
@@ -22086,6 +22102,12 @@
|
||||
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
|
||||
"integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA=="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
||||
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-error": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
@@ -12,7 +14,7 @@
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
"check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.1.0",
|
||||
@@ -32,6 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.1.0",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
|
||||
@@ -14,8 +14,8 @@ const FeatureList: FeatureItem[] = [
|
||||
Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
|
||||
description: (
|
||||
<>
|
||||
Docusaurus was designed from the ground up to be easily installed and
|
||||
used to get your website up and running quickly.
|
||||
Docusaurus was designed from the ground up to be easily installed and used to get your website up and running
|
||||
quickly.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -24,8 +24,8 @@ const FeatureList: FeatureItem[] = [
|
||||
Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
|
||||
description: (
|
||||
<>
|
||||
Docusaurus lets you focus on your docs, and we'll do the chores. Go
|
||||
ahead and move your docs into the <code>docs</code> directory.
|
||||
Docusaurus lets you focus on your docs, and we'll do the chores. Go ahead and move your docs into the{' '}
|
||||
<code>docs</code> directory.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -34,14 +34,14 @@ const FeatureList: FeatureItem[] = [
|
||||
Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
|
||||
description: (
|
||||
<>
|
||||
Extend or customize your website layout by reusing React. Docusaurus can
|
||||
be extended while reusing the same header and footer.
|
||||
Extend or customize your website layout by reusing React. Docusaurus can be extended while reusing the same
|
||||
header and footer.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({title, Svg, description}: FeatureItem) {
|
||||
function Feature({ title, Svg, description }: FeatureItem) {
|
||||
return (
|
||||
<div className={clsx('col col--4')}>
|
||||
<div className="text--center">
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Overpass:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Snowburst+One&display=swap");
|
||||
@import url('https://fonts.googleapis.com/css2?family=Overpass:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Snowburst+One&display=swap');
|
||||
|
||||
html,
|
||||
button {
|
||||
font-family: "Overpass", sans-serif;
|
||||
font-family: 'Overpass', sans-serif;
|
||||
}
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
@@ -29,7 +29,7 @@ button {
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme="dark"] {
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #adcbfa;
|
||||
--ifm-color-primary-dark: #85b2f8;
|
||||
--ifm-color-primary-darker: #71a5f6;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import Layout from "@theme/Layout";
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import Layout from '@theme/Layout';
|
||||
|
||||
function HomepageHeader() {
|
||||
return (
|
||||
@@ -31,7 +31,7 @@ function HomepageHeader() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<img src="/img/immich-screenshots.webp" alt="logo" />
|
||||
<img src="/img/immich-screenshots.png" alt="logo" />
|
||||
</section>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,297 +1,256 @@
|
||||
import Hogan from "hogan.js";
|
||||
import LunrSearchAdapter from "./lunar-search";
|
||||
import autocomplete from "autocomplete.js";
|
||||
import templates from "./templates";
|
||||
import utils from "./utils";
|
||||
import $ from "autocomplete.js/zepto";
|
||||
import Hogan from 'hogan.js';
|
||||
import LunrSearchAdapter from './lunar-search';
|
||||
import autocomplete from 'autocomplete.js';
|
||||
import templates from './templates';
|
||||
import utils from './utils';
|
||||
import $ from 'autocomplete.js/zepto';
|
||||
|
||||
class DocSearch {
|
||||
constructor({
|
||||
searchDocs,
|
||||
searchIndex,
|
||||
inputSelector,
|
||||
debug = false,
|
||||
baseUrl = '/',
|
||||
queryDataCallback = null,
|
||||
autocompleteOptions = {
|
||||
debug: false,
|
||||
hint: false,
|
||||
autoselect: true
|
||||
constructor({
|
||||
searchDocs,
|
||||
searchIndex,
|
||||
inputSelector,
|
||||
debug = false,
|
||||
baseUrl = '/',
|
||||
queryDataCallback = null,
|
||||
autocompleteOptions = {
|
||||
debug: false,
|
||||
hint: false,
|
||||
autoselect: true,
|
||||
},
|
||||
transformData = false,
|
||||
queryHook = false,
|
||||
handleSelected = false,
|
||||
enhancedSearchInput = false,
|
||||
layout = 'collumns',
|
||||
}) {
|
||||
this.input = DocSearch.getInputFromSelector(inputSelector);
|
||||
this.queryDataCallback = queryDataCallback || null;
|
||||
const autocompleteOptionsDebug =
|
||||
autocompleteOptions && autocompleteOptions.debug ? autocompleteOptions.debug : false;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
autocompleteOptions.debug = debug || autocompleteOptionsDebug;
|
||||
this.autocompleteOptions = autocompleteOptions;
|
||||
this.autocompleteOptions.cssClasses = this.autocompleteOptions.cssClasses || {};
|
||||
this.autocompleteOptions.cssClasses.prefix = this.autocompleteOptions.cssClasses.prefix || 'ds';
|
||||
const inputAriaLabel = this.input && typeof this.input.attr === 'function' && this.input.attr('aria-label');
|
||||
this.autocompleteOptions.ariaLabel = this.autocompleteOptions.ariaLabel || inputAriaLabel || 'search input';
|
||||
|
||||
this.isSimpleLayout = layout === 'simple';
|
||||
|
||||
this.client = new LunrSearchAdapter(searchDocs, searchIndex, baseUrl);
|
||||
|
||||
if (enhancedSearchInput) {
|
||||
this.input = DocSearch.injectSearchBox(this.input);
|
||||
}
|
||||
this.autocomplete = autocomplete(this.input, autocompleteOptions, [
|
||||
{
|
||||
source: this.getAutocompleteSource(transformData, queryHook),
|
||||
templates: {
|
||||
suggestion: DocSearch.getSuggestionTemplate(this.isSimpleLayout),
|
||||
footer: templates.footer,
|
||||
empty: DocSearch.getEmptyTemplate(),
|
||||
},
|
||||
transformData = false,
|
||||
queryHook = false,
|
||||
handleSelected = false,
|
||||
enhancedSearchInput = false,
|
||||
layout = "collumns"
|
||||
}) {
|
||||
this.input = DocSearch.getInputFromSelector(inputSelector);
|
||||
this.queryDataCallback = queryDataCallback || null;
|
||||
const autocompleteOptionsDebug =
|
||||
autocompleteOptions && autocompleteOptions.debug
|
||||
? autocompleteOptions.debug
|
||||
: false;
|
||||
},
|
||||
]);
|
||||
|
||||
const customHandleSelected = handleSelected;
|
||||
this.handleSelected = customHandleSelected || this.handleSelected;
|
||||
|
||||
// We prevent default link clicking if a custom handleSelected is defined
|
||||
if (customHandleSelected) {
|
||||
$('.algolia-autocomplete').on('click', '.ds-suggestions a', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
this.autocomplete.on('autocomplete:selected', this.handleSelected.bind(null, this.autocomplete.autocomplete));
|
||||
|
||||
this.autocomplete.on('autocomplete:shown', this.handleShown.bind(null, this.input));
|
||||
|
||||
if (enhancedSearchInput) {
|
||||
DocSearch.bindSearchBoxEvent();
|
||||
}
|
||||
}
|
||||
|
||||
static injectSearchBox(input) {
|
||||
input.before(templates.searchBox);
|
||||
const newInput = input.prev().prev().find('input');
|
||||
input.remove();
|
||||
return newInput;
|
||||
}
|
||||
|
||||
static bindSearchBoxEvent() {
|
||||
$('.searchbox [type="reset"]').on('click', function () {
|
||||
$('input#docsearch').focus();
|
||||
$(this).addClass('hide');
|
||||
autocomplete.autocomplete.setVal('');
|
||||
});
|
||||
|
||||
$('input#docsearch').on('keyup', () => {
|
||||
const searchbox = document.querySelector('input#docsearch');
|
||||
const reset = document.querySelector('.searchbox [type="reset"]');
|
||||
reset.className = 'searchbox__reset';
|
||||
if (searchbox.value.length === 0) {
|
||||
reset.className += ' hide';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the matching input from a CSS selector, null if none matches
|
||||
* @function getInputFromSelector
|
||||
* @param {string} selector CSS selector that matches the search
|
||||
* input of the page
|
||||
* @returns {void}
|
||||
*/
|
||||
static getInputFromSelector(selector) {
|
||||
const input = $(selector).filter('input');
|
||||
return input.length ? $(input[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `source` method to be passed to autocomplete.js. It will query
|
||||
* the Algolia index and call the callbacks with the formatted hits.
|
||||
* @function getAutocompleteSource
|
||||
* @param {function} transformData An optional function to transform the hits
|
||||
* @param {function} queryHook An optional function to transform the query
|
||||
* @returns {function} Method to be passed as the `source` option of
|
||||
* autocomplete
|
||||
*/
|
||||
getAutocompleteSource(transformData, queryHook) {
|
||||
return (query, callback) => {
|
||||
if (queryHook) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
autocompleteOptions.debug = debug || autocompleteOptionsDebug;
|
||||
this.autocompleteOptions = autocompleteOptions;
|
||||
this.autocompleteOptions.cssClasses =
|
||||
this.autocompleteOptions.cssClasses || {};
|
||||
this.autocompleteOptions.cssClasses.prefix =
|
||||
this.autocompleteOptions.cssClasses.prefix || "ds";
|
||||
const inputAriaLabel =
|
||||
this.input &&
|
||||
typeof this.input.attr === "function" &&
|
||||
this.input.attr("aria-label");
|
||||
this.autocompleteOptions.ariaLabel =
|
||||
this.autocompleteOptions.ariaLabel || inputAriaLabel || "search input";
|
||||
|
||||
this.isSimpleLayout = layout === "simple";
|
||||
|
||||
this.client = new LunrSearchAdapter(searchDocs, searchIndex, baseUrl);
|
||||
|
||||
if (enhancedSearchInput) {
|
||||
this.input = DocSearch.injectSearchBox(this.input);
|
||||
query = queryHook(query) || query;
|
||||
}
|
||||
this.client.search(query).then((hits) => {
|
||||
if (this.queryDataCallback && typeof this.queryDataCallback == 'function') {
|
||||
this.queryDataCallback(hits);
|
||||
}
|
||||
this.autocomplete = autocomplete(this.input, autocompleteOptions, [
|
||||
{
|
||||
source: this.getAutocompleteSource(transformData, queryHook),
|
||||
templates: {
|
||||
suggestion: DocSearch.getSuggestionTemplate(this.isSimpleLayout),
|
||||
footer: templates.footer,
|
||||
empty: DocSearch.getEmptyTemplate()
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const customHandleSelected = handleSelected;
|
||||
this.handleSelected = customHandleSelected || this.handleSelected;
|
||||
|
||||
// We prevent default link clicking if a custom handleSelected is defined
|
||||
if (customHandleSelected) {
|
||||
$(".algolia-autocomplete").on("click", ".ds-suggestions a", event => {
|
||||
event.preventDefault();
|
||||
});
|
||||
if (transformData) {
|
||||
hits = transformData(hits) || hits;
|
||||
}
|
||||
callback(DocSearch.formatHits(hits));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
this.autocomplete.on(
|
||||
"autocomplete:selected",
|
||||
this.handleSelected.bind(null, this.autocomplete.autocomplete)
|
||||
);
|
||||
// Given a list of hits returned by the API, will reformat them to be used in
|
||||
// a Hogan template
|
||||
static formatHits(receivedHits) {
|
||||
const clonedHits = utils.deepClone(receivedHits);
|
||||
const hits = clonedHits.map((hit) => {
|
||||
if (hit._highlightResult) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
hit._highlightResult = utils.mergeKeyWithParent(hit._highlightResult, 'hierarchy');
|
||||
}
|
||||
return utils.mergeKeyWithParent(hit, 'hierarchy');
|
||||
});
|
||||
|
||||
this.autocomplete.on(
|
||||
"autocomplete:shown",
|
||||
this.handleShown.bind(null, this.input)
|
||||
);
|
||||
// Group hits by category / subcategory
|
||||
let groupedHits = utils.groupBy(hits, 'lvl0');
|
||||
$.each(groupedHits, (level, collection) => {
|
||||
const groupedHitsByLvl1 = utils.groupBy(collection, 'lvl1');
|
||||
const flattenedHits = utils.flattenAndFlagFirst(groupedHitsByLvl1, 'isSubCategoryHeader');
|
||||
groupedHits[level] = flattenedHits;
|
||||
});
|
||||
groupedHits = utils.flattenAndFlagFirst(groupedHits, 'isCategoryHeader');
|
||||
|
||||
if (enhancedSearchInput) {
|
||||
DocSearch.bindSearchBoxEvent();
|
||||
}
|
||||
// Translate hits into smaller objects to be send to the template
|
||||
return groupedHits.map((hit) => {
|
||||
const url = DocSearch.formatURL(hit);
|
||||
const category = utils.getHighlightedValue(hit, 'lvl0');
|
||||
const subcategory = utils.getHighlightedValue(hit, 'lvl1') || category;
|
||||
const displayTitle = utils
|
||||
.compact([
|
||||
utils.getHighlightedValue(hit, 'lvl2') || subcategory,
|
||||
utils.getHighlightedValue(hit, 'lvl3'),
|
||||
utils.getHighlightedValue(hit, 'lvl4'),
|
||||
utils.getHighlightedValue(hit, 'lvl5'),
|
||||
utils.getHighlightedValue(hit, 'lvl6'),
|
||||
])
|
||||
.join('<span class="aa-suggestion-title-separator" aria-hidden="true"> › </span>');
|
||||
const text = utils.getSnippetedValue(hit, 'content');
|
||||
const isTextOrSubcategoryNonEmpty = (subcategory && subcategory !== '') || (displayTitle && displayTitle !== '');
|
||||
const isLvl1EmptyOrDuplicate = !subcategory || subcategory === '' || subcategory === category;
|
||||
const isLvl2 = displayTitle && displayTitle !== '' && displayTitle !== subcategory;
|
||||
const isLvl1 = !isLvl2 && subcategory && subcategory !== '' && subcategory !== category;
|
||||
const isLvl0 = !isLvl1 && !isLvl2;
|
||||
|
||||
return {
|
||||
isLvl0,
|
||||
isLvl1,
|
||||
isLvl2,
|
||||
isLvl1EmptyOrDuplicate,
|
||||
isCategoryHeader: hit.isCategoryHeader,
|
||||
isSubCategoryHeader: hit.isSubCategoryHeader,
|
||||
isTextOrSubcategoryNonEmpty,
|
||||
category,
|
||||
subcategory,
|
||||
title: displayTitle,
|
||||
text,
|
||||
url,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static formatURL(hit) {
|
||||
const { url, anchor } = hit;
|
||||
if (url) {
|
||||
const containsAnchor = url.indexOf('#') !== -1;
|
||||
if (containsAnchor) return url;
|
||||
else if (anchor) return `${hit.url}#${hit.anchor}`;
|
||||
return url;
|
||||
} else if (anchor) return `#${hit.anchor}`;
|
||||
/* eslint-disable */
|
||||
console.warn('no anchor nor url for : ', JSON.stringify(hit));
|
||||
/* eslint-enable */
|
||||
return null;
|
||||
}
|
||||
|
||||
static getEmptyTemplate() {
|
||||
return (args) => Hogan.compile(templates.empty).render(args);
|
||||
}
|
||||
|
||||
static getSuggestionTemplate(isSimpleLayout) {
|
||||
const stringTemplate = isSimpleLayout ? templates.suggestionSimple : templates.suggestion;
|
||||
const template = Hogan.compile(stringTemplate);
|
||||
return (suggestion) => template.render(suggestion);
|
||||
}
|
||||
|
||||
handleSelected(input, event, suggestion, datasetNumber, context = {}) {
|
||||
// Do nothing if click on the suggestion, as it's already a <a href>, the
|
||||
// browser will take care of it. This allow Ctrl-Clicking on results and not
|
||||
// having the main window being redirected as well
|
||||
if (context.selectionMethod === 'click') {
|
||||
return;
|
||||
}
|
||||
|
||||
static injectSearchBox(input) {
|
||||
input.before(templates.searchBox);
|
||||
const newInput = input
|
||||
.prev()
|
||||
.prev()
|
||||
.find("input");
|
||||
input.remove();
|
||||
return newInput;
|
||||
input.setVal('');
|
||||
window.location.assign(suggestion.url);
|
||||
}
|
||||
|
||||
handleShown(input) {
|
||||
const middleOfInput = input.offset().left + input.width() / 2;
|
||||
let middleOfWindow = $(document).width() / 2;
|
||||
|
||||
if (isNaN(middleOfWindow)) {
|
||||
middleOfWindow = 900;
|
||||
}
|
||||
|
||||
static bindSearchBoxEvent() {
|
||||
$('.searchbox [type="reset"]').on("click", function () {
|
||||
$("input#docsearch").focus();
|
||||
$(this).addClass("hide");
|
||||
autocomplete.autocomplete.setVal("");
|
||||
});
|
||||
|
||||
$("input#docsearch").on("keyup", () => {
|
||||
const searchbox = document.querySelector("input#docsearch");
|
||||
const reset = document.querySelector('.searchbox [type="reset"]');
|
||||
reset.className = "searchbox__reset";
|
||||
if (searchbox.value.length === 0) {
|
||||
reset.className += " hide";
|
||||
}
|
||||
});
|
||||
const alignClass = middleOfInput - middleOfWindow >= 0 ? 'algolia-autocomplete-right' : 'algolia-autocomplete-left';
|
||||
const otherAlignClass =
|
||||
middleOfInput - middleOfWindow < 0 ? 'algolia-autocomplete-right' : 'algolia-autocomplete-left';
|
||||
const autocompleteWrapper = $('.algolia-autocomplete');
|
||||
if (!autocompleteWrapper.hasClass(alignClass)) {
|
||||
autocompleteWrapper.addClass(alignClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the matching input from a CSS selector, null if none matches
|
||||
* @function getInputFromSelector
|
||||
* @param {string} selector CSS selector that matches the search
|
||||
* input of the page
|
||||
* @returns {void}
|
||||
*/
|
||||
static getInputFromSelector(selector) {
|
||||
const input = $(selector).filter("input");
|
||||
return input.length ? $(input[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `source` method to be passed to autocomplete.js. It will query
|
||||
* the Algolia index and call the callbacks with the formatted hits.
|
||||
* @function getAutocompleteSource
|
||||
* @param {function} transformData An optional function to transform the hits
|
||||
* @param {function} queryHook An optional function to transform the query
|
||||
* @returns {function} Method to be passed as the `source` option of
|
||||
* autocomplete
|
||||
*/
|
||||
getAutocompleteSource(transformData, queryHook) {
|
||||
return (query, callback) => {
|
||||
if (queryHook) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
query = queryHook(query) || query;
|
||||
}
|
||||
this.client.search(query).then(hits => {
|
||||
if (
|
||||
this.queryDataCallback &&
|
||||
typeof this.queryDataCallback == "function"
|
||||
) {
|
||||
this.queryDataCallback(hits);
|
||||
}
|
||||
if (transformData) {
|
||||
hits = transformData(hits) || hits;
|
||||
}
|
||||
callback(DocSearch.formatHits(hits));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Given a list of hits returned by the API, will reformat them to be used in
|
||||
// a Hogan template
|
||||
static formatHits(receivedHits) {
|
||||
const clonedHits = utils.deepClone(receivedHits);
|
||||
const hits = clonedHits.map(hit => {
|
||||
if (hit._highlightResult) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
hit._highlightResult = utils.mergeKeyWithParent(
|
||||
hit._highlightResult,
|
||||
"hierarchy"
|
||||
);
|
||||
}
|
||||
return utils.mergeKeyWithParent(hit, "hierarchy");
|
||||
});
|
||||
|
||||
// Group hits by category / subcategory
|
||||
let groupedHits = utils.groupBy(hits, "lvl0");
|
||||
$.each(groupedHits, (level, collection) => {
|
||||
const groupedHitsByLvl1 = utils.groupBy(collection, "lvl1");
|
||||
const flattenedHits = utils.flattenAndFlagFirst(
|
||||
groupedHitsByLvl1,
|
||||
"isSubCategoryHeader"
|
||||
);
|
||||
groupedHits[level] = flattenedHits;
|
||||
});
|
||||
groupedHits = utils.flattenAndFlagFirst(groupedHits, "isCategoryHeader");
|
||||
|
||||
// Translate hits into smaller objects to be send to the template
|
||||
return groupedHits.map(hit => {
|
||||
const url = DocSearch.formatURL(hit);
|
||||
const category = utils.getHighlightedValue(hit, "lvl0");
|
||||
const subcategory = utils.getHighlightedValue(hit, "lvl1") || category;
|
||||
const displayTitle = utils
|
||||
.compact([
|
||||
utils.getHighlightedValue(hit, "lvl2") || subcategory,
|
||||
utils.getHighlightedValue(hit, "lvl3"),
|
||||
utils.getHighlightedValue(hit, "lvl4"),
|
||||
utils.getHighlightedValue(hit, "lvl5"),
|
||||
utils.getHighlightedValue(hit, "lvl6")
|
||||
])
|
||||
.join(
|
||||
'<span class="aa-suggestion-title-separator" aria-hidden="true"> › </span>'
|
||||
);
|
||||
const text = utils.getSnippetedValue(hit, "content");
|
||||
const isTextOrSubcategoryNonEmpty =
|
||||
(subcategory && subcategory !== "") ||
|
||||
(displayTitle && displayTitle !== "");
|
||||
const isLvl1EmptyOrDuplicate =
|
||||
!subcategory || subcategory === "" || subcategory === category;
|
||||
const isLvl2 =
|
||||
displayTitle && displayTitle !== "" && displayTitle !== subcategory;
|
||||
const isLvl1 =
|
||||
!isLvl2 &&
|
||||
(subcategory && subcategory !== "" && subcategory !== category);
|
||||
const isLvl0 = !isLvl1 && !isLvl2;
|
||||
|
||||
return {
|
||||
isLvl0,
|
||||
isLvl1,
|
||||
isLvl2,
|
||||
isLvl1EmptyOrDuplicate,
|
||||
isCategoryHeader: hit.isCategoryHeader,
|
||||
isSubCategoryHeader: hit.isSubCategoryHeader,
|
||||
isTextOrSubcategoryNonEmpty,
|
||||
category,
|
||||
subcategory,
|
||||
title: displayTitle,
|
||||
text,
|
||||
url
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static formatURL(hit) {
|
||||
const { url, anchor } = hit;
|
||||
if (url) {
|
||||
const containsAnchor = url.indexOf("#") !== -1;
|
||||
if (containsAnchor) return url;
|
||||
else if (anchor) return `${hit.url}#${hit.anchor}`;
|
||||
return url;
|
||||
} else if (anchor) return `#${hit.anchor}`;
|
||||
/* eslint-disable */
|
||||
console.warn("no anchor nor url for : ", JSON.stringify(hit));
|
||||
/* eslint-enable */
|
||||
return null;
|
||||
}
|
||||
|
||||
static getEmptyTemplate() {
|
||||
return args => Hogan.compile(templates.empty).render(args);
|
||||
}
|
||||
|
||||
static getSuggestionTemplate(isSimpleLayout) {
|
||||
const stringTemplate = isSimpleLayout
|
||||
? templates.suggestionSimple
|
||||
: templates.suggestion;
|
||||
const template = Hogan.compile(stringTemplate);
|
||||
return suggestion => template.render(suggestion);
|
||||
}
|
||||
|
||||
handleSelected(input, event, suggestion, datasetNumber, context = {}) {
|
||||
// Do nothing if click on the suggestion, as it's already a <a href>, the
|
||||
// browser will take care of it. This allow Ctrl-Clicking on results and not
|
||||
// having the main window being redirected as well
|
||||
if (context.selectionMethod === "click") {
|
||||
return;
|
||||
}
|
||||
|
||||
input.setVal("");
|
||||
window.location.assign(suggestion.url);
|
||||
}
|
||||
|
||||
handleShown(input) {
|
||||
const middleOfInput = input.offset().left + input.width() / 2;
|
||||
let middleOfWindow = $(document).width() / 2;
|
||||
|
||||
if (isNaN(middleOfWindow)) {
|
||||
middleOfWindow = 900;
|
||||
}
|
||||
|
||||
const alignClass =
|
||||
middleOfInput - middleOfWindow >= 0
|
||||
? "algolia-autocomplete-right"
|
||||
: "algolia-autocomplete-left";
|
||||
const otherAlignClass =
|
||||
middleOfInput - middleOfWindow < 0
|
||||
? "algolia-autocomplete-right"
|
||||
: "algolia-autocomplete-left";
|
||||
const autocompleteWrapper = $(".algolia-autocomplete");
|
||||
if (!autocompleteWrapper.hasClass(alignClass)) {
|
||||
autocompleteWrapper.addClass(alignClass);
|
||||
}
|
||||
|
||||
if (autocompleteWrapper.hasClass(otherAlignClass)) {
|
||||
autocompleteWrapper.removeClass(otherAlignClass);
|
||||
}
|
||||
if (autocompleteWrapper.hasClass(otherAlignClass)) {
|
||||
autocompleteWrapper.removeClass(otherAlignClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DocSearch;
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
color: #3a33d1;
|
||||
}
|
||||
/* Highligted search terms in the main category headers */
|
||||
.algolia-docsearch-suggestion--category-header
|
||||
.algolia-docsearch-suggestion--highlight {
|
||||
.algolia-docsearch-suggestion--category-header .algolia-docsearch-suggestion--highlight {
|
||||
background-color: #4d47d5;
|
||||
}
|
||||
/* Currently selected suggestion */
|
||||
@@ -343,9 +342,7 @@
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.algolia-autocomplete
|
||||
.algolia-docsearch-suggestion--text
|
||||
.algolia-docsearch-suggestion--highlight {
|
||||
.algolia-autocomplete .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight {
|
||||
padding: 0 0 1px;
|
||||
background: inherit;
|
||||
box-shadow: inset 0 -2px 0 0 rgba(69, 142, 225, 0.8);
|
||||
@@ -419,14 +416,11 @@
|
||||
.algolia-autocomplete
|
||||
.algolia-docsearch-suggestion.algolia-docsearch-suggestion__main
|
||||
.algolia-docsearch-suggestion--category-header,
|
||||
.algolia-autocomplete
|
||||
.algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary {
|
||||
.algolia-autocomplete .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.algolia-autocomplete
|
||||
.algolia-docsearch-suggestion--subcategory-column
|
||||
.algolia-docsearch-suggestion--highlight {
|
||||
.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column .algolia-docsearch-suggestion--highlight {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -459,9 +453,7 @@
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.algolia-autocomplete
|
||||
.algolia-docsearch-suggestion--no-results
|
||||
.algolia-docsearch-suggestion--text {
|
||||
.algolia-autocomplete .algolia-docsearch-suggestion--no-results .algolia-docsearch-suggestion--text {
|
||||
color: #ffffff;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -477,14 +469,10 @@
|
||||
color: #222222;
|
||||
background-color: #ebebeb;
|
||||
border-radius: 3px;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.algolia-autocomplete
|
||||
.algolia-docsearch-suggestion
|
||||
code
|
||||
.algolia-docsearch-suggestion--highlight {
|
||||
.algolia-autocomplete .algolia-docsearch-suggestion code .algolia-docsearch-suggestion--highlight {
|
||||
background: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useRef, useCallback, useState } from "react";
|
||||
import classnames from "classnames";
|
||||
import { useHistory } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useHistory } from '@docusaurus/router';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import { usePluginData } from '@docusaurus/useGlobalData';
|
||||
import useIsBrowser from "@docusaurus/useIsBrowser";
|
||||
const Search = props => {
|
||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||
const Search = (props) => {
|
||||
const initialized = useRef(false);
|
||||
const searchBarRef = useRef(null);
|
||||
const [indexReady, setIndexReady] = useState(false);
|
||||
@@ -13,65 +13,62 @@ const Search = props => {
|
||||
const isBrowser = useIsBrowser();
|
||||
const { baseUrl } = siteConfig;
|
||||
const initAlgolia = (searchDocs, searchIndex, DocSearch) => {
|
||||
new DocSearch({
|
||||
searchDocs,
|
||||
searchIndex,
|
||||
baseUrl,
|
||||
inputSelector: "#search_input_react",
|
||||
// Override algolia's default selection event, allowing us to do client-side
|
||||
// navigation and avoiding a full page refresh.
|
||||
handleSelected: (_input, _event, suggestion) => {
|
||||
const url = suggestion.url || "/";
|
||||
// Use an anchor tag to parse the absolute url into a relative url
|
||||
// Alternatively, we can use new URL(suggestion.url) but its not supported in IE
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
// Algolia use closest parent element id #__docusaurus when a h1 page title does not have an id
|
||||
// So, we can safely remove it. See https://github.com/facebook/docusaurus/issues/1828 for more details.
|
||||
new DocSearch({
|
||||
searchDocs,
|
||||
searchIndex,
|
||||
baseUrl,
|
||||
inputSelector: '#search_input_react',
|
||||
// Override algolia's default selection event, allowing us to do client-side
|
||||
// navigation and avoiding a full page refresh.
|
||||
handleSelected: (_input, _event, suggestion) => {
|
||||
const url = suggestion.url || '/';
|
||||
// Use an anchor tag to parse the absolute url into a relative url
|
||||
// Alternatively, we can use new URL(suggestion.url) but its not supported in IE
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Algolia use closest parent element id #__docusaurus when a h1 page title does not have an id
|
||||
// So, we can safely remove it. See https://github.com/facebook/docusaurus/issues/1828 for more details.
|
||||
|
||||
history.push(url);
|
||||
}
|
||||
});
|
||||
history.push(url);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const pluginData = usePluginData('docusaurus-lunr-search');
|
||||
const getSearchDoc = () =>
|
||||
process.env.NODE_ENV === "production"
|
||||
process.env.NODE_ENV === 'production'
|
||||
? fetch(`${baseUrl}${pluginData.fileNames.searchDoc}`).then((content) => content.json())
|
||||
: Promise.resolve([]);
|
||||
|
||||
const getLunrIndex = () =>
|
||||
process.env.NODE_ENV === "production"
|
||||
process.env.NODE_ENV === 'production'
|
||||
? fetch(`${baseUrl}${pluginData.fileNames.lunrIndex}`).then((content) => content.json())
|
||||
: Promise.resolve([]);
|
||||
|
||||
const loadAlgolia = () => {
|
||||
if (!initialized.current) {
|
||||
Promise.all([
|
||||
getSearchDoc(),
|
||||
getLunrIndex(),
|
||||
import("./DocSearch"),
|
||||
import("./algolia.css")
|
||||
]).then(([searchDocs, searchIndex, { default: DocSearch }]) => {
|
||||
if (searchDocs.length === 0) {
|
||||
return;
|
||||
}
|
||||
initAlgolia(searchDocs, searchIndex, DocSearch);
|
||||
setIndexReady(true);
|
||||
});
|
||||
Promise.all([getSearchDoc(), getLunrIndex(), import('./DocSearch'), import('./algolia.css')]).then(
|
||||
([searchDocs, searchIndex, { default: DocSearch }]) => {
|
||||
if (searchDocs.length === 0) {
|
||||
return;
|
||||
}
|
||||
initAlgolia(searchDocs, searchIndex, DocSearch);
|
||||
setIndexReady(true);
|
||||
},
|
||||
);
|
||||
initialized.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSearchIconClick = useCallback(
|
||||
e => {
|
||||
(e) => {
|
||||
if (!searchBarRef.current.contains(e.target)) {
|
||||
searchBarRef.current.focus();
|
||||
}
|
||||
|
||||
props.handleSearchBarToggle && props.handleSearchBarToggle(!props.isSearchBarExpanded);
|
||||
},
|
||||
[props.isSearchBarExpanded]
|
||||
[props.isSearchBarExpanded],
|
||||
);
|
||||
|
||||
if (isBrowser) {
|
||||
@@ -83,8 +80,8 @@ const Search = props => {
|
||||
<span
|
||||
aria-label="expand searchbar"
|
||||
role="button"
|
||||
className={classnames("search-icon", {
|
||||
"search-icon-hidden": props.isSearchBarExpanded
|
||||
className={classnames('search-icon', {
|
||||
'search-icon-hidden': props.isSearchBarExpanded,
|
||||
})}
|
||||
onClick={toggleSearchIconClick}
|
||||
onKeyDown={toggleSearchIconClick}
|
||||
@@ -96,9 +93,9 @@ const Search = props => {
|
||||
placeholder={indexReady ? 'Search' : 'Loading...'}
|
||||
aria-label="Search"
|
||||
className={classnames(
|
||||
"navbar__search-input",
|
||||
{ "search-bar-expanded": props.isSearchBarExpanded },
|
||||
{ "search-bar": !props.isSearchBarExpanded }
|
||||
'navbar__search-input',
|
||||
{ 'search-bar-expanded': props.isSearchBarExpanded },
|
||||
{ 'search-bar': !props.isSearchBarExpanded },
|
||||
)}
|
||||
onClick={loadAlgolia}
|
||||
onMouseOver={loadAlgolia}
|
||||
|
||||
@@ -1,147 +1,161 @@
|
||||
import lunr from "@generated/lunr.client";
|
||||
import lunr from '@generated/lunr.client';
|
||||
lunr.tokenizer.separator = /[\s\-/]+/;
|
||||
|
||||
class LunrSearchAdapter {
|
||||
constructor(searchDocs, searchIndex, baseUrl = '/') {
|
||||
this.searchDocs = searchDocs;
|
||||
this.lunrIndex = lunr.Index.load(searchIndex);
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
constructor(searchDocs, searchIndex, baseUrl = '/') {
|
||||
this.searchDocs = searchDocs;
|
||||
this.lunrIndex = lunr.Index.load(searchIndex);
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
getLunrResult(input) {
|
||||
return this.lunrIndex.query(function (query) {
|
||||
const tokens = lunr.tokenizer(input);
|
||||
query.term(tokens, {
|
||||
boost: 10
|
||||
});
|
||||
query.term(tokens, {
|
||||
wildcard: lunr.Query.wildcard.TRAILING
|
||||
});
|
||||
});
|
||||
}
|
||||
getLunrResult(input) {
|
||||
return this.lunrIndex.query(function (query) {
|
||||
const tokens = lunr.tokenizer(input);
|
||||
query.term(tokens, {
|
||||
boost: 10,
|
||||
});
|
||||
query.term(tokens, {
|
||||
wildcard: lunr.Query.wildcard.TRAILING,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getHit(doc, formattedTitle, formattedContent) {
|
||||
return {
|
||||
hierarchy: {
|
||||
lvl0: doc.pageTitle || doc.title,
|
||||
lvl1: doc.type === 0 ? null : doc.title
|
||||
getHit(doc, formattedTitle, formattedContent) {
|
||||
return {
|
||||
hierarchy: {
|
||||
lvl0: doc.pageTitle || doc.title,
|
||||
lvl1: doc.type === 0 ? null : doc.title,
|
||||
},
|
||||
url: doc.url,
|
||||
_snippetResult: formattedContent
|
||||
? {
|
||||
content: {
|
||||
value: formattedContent,
|
||||
matchLevel: 'full',
|
||||
},
|
||||
url: doc.url,
|
||||
_snippetResult: formattedContent ? {
|
||||
content: {
|
||||
value: formattedContent,
|
||||
matchLevel: "full"
|
||||
}
|
||||
} : null,
|
||||
_highlightResult: {
|
||||
hierarchy: {
|
||||
lvl0: {
|
||||
value: doc.type === 0 ? formattedTitle || doc.title : doc.pageTitle,
|
||||
},
|
||||
lvl1:
|
||||
doc.type === 0
|
||||
? null
|
||||
: {
|
||||
value: formattedTitle || doc.title
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
getTitleHit(doc, position, length) {
|
||||
const start = position[0];
|
||||
const end = position[0] + length;
|
||||
let formattedTitle = doc.title.substring(0, start) + '<span class="algolia-docsearch-suggestion--highlight">' + doc.title.substring(start, end) + '</span>' + doc.title.substring(end, doc.title.length);
|
||||
return this.getHit(doc, formattedTitle)
|
||||
}
|
||||
}
|
||||
: null,
|
||||
_highlightResult: {
|
||||
hierarchy: {
|
||||
lvl0: {
|
||||
value: doc.type === 0 ? formattedTitle || doc.title : doc.pageTitle,
|
||||
},
|
||||
lvl1:
|
||||
doc.type === 0
|
||||
? null
|
||||
: {
|
||||
value: formattedTitle || doc.title,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
getTitleHit(doc, position, length) {
|
||||
const start = position[0];
|
||||
const end = position[0] + length;
|
||||
let formattedTitle =
|
||||
doc.title.substring(0, start) +
|
||||
'<span class="algolia-docsearch-suggestion--highlight">' +
|
||||
doc.title.substring(start, end) +
|
||||
'</span>' +
|
||||
doc.title.substring(end, doc.title.length);
|
||||
return this.getHit(doc, formattedTitle);
|
||||
}
|
||||
|
||||
getKeywordHit(doc, position, length) {
|
||||
const start = position[0];
|
||||
const end = position[0] + length;
|
||||
let formattedTitle = doc.title + '<br /><i>Keywords: ' + doc.keywords.substring(0, start) + '<span class="algolia-docsearch-suggestion--highlight">' + doc.keywords.substring(start, end) + '</span>' + doc.keywords.substring(end, doc.keywords.length) + '</i>'
|
||||
return this.getHit(doc, formattedTitle)
|
||||
}
|
||||
|
||||
getContentHit(doc, position) {
|
||||
const start = position[0];
|
||||
const end = position[0] + position[1];
|
||||
let previewStart = start;
|
||||
let previewEnd = end;
|
||||
let ellipsesBefore = true;
|
||||
let ellipsesAfter = true;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const nextSpace = doc.content.lastIndexOf(' ', previewStart - 2);
|
||||
const nextDot = doc.content.lastIndexOf('.', previewStart - 2);
|
||||
if ((nextDot > 0) && (nextDot > nextSpace)) {
|
||||
previewStart = nextDot + 1;
|
||||
ellipsesBefore = false;
|
||||
break;
|
||||
}
|
||||
if (nextSpace < 0) {
|
||||
previewStart = 0;
|
||||
ellipsesBefore = false;
|
||||
break;
|
||||
}
|
||||
previewStart = nextSpace + 1;
|
||||
}
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const nextSpace = doc.content.indexOf(' ', previewEnd + 1);
|
||||
const nextDot = doc.content.indexOf('.', previewEnd + 1);
|
||||
if ((nextDot > 0) && (nextDot < nextSpace)) {
|
||||
previewEnd = nextDot;
|
||||
ellipsesAfter = false;
|
||||
break;
|
||||
}
|
||||
if (nextSpace < 0) {
|
||||
previewEnd = doc.content.length;
|
||||
ellipsesAfter = false;
|
||||
break;
|
||||
}
|
||||
previewEnd = nextSpace;
|
||||
}
|
||||
let preview = doc.content.substring(previewStart, start);
|
||||
if (ellipsesBefore) {
|
||||
preview = '... ' + preview;
|
||||
}
|
||||
preview += '<span class="algolia-docsearch-suggestion--highlight">' + doc.content.substring(start, end) + '</span>';
|
||||
preview += doc.content.substring(end, previewEnd);
|
||||
if (ellipsesAfter) {
|
||||
preview += ' ...';
|
||||
}
|
||||
return this.getHit(doc, null, preview);
|
||||
getKeywordHit(doc, position, length) {
|
||||
const start = position[0];
|
||||
const end = position[0] + length;
|
||||
let formattedTitle =
|
||||
doc.title +
|
||||
'<br /><i>Keywords: ' +
|
||||
doc.keywords.substring(0, start) +
|
||||
'<span class="algolia-docsearch-suggestion--highlight">' +
|
||||
doc.keywords.substring(start, end) +
|
||||
'</span>' +
|
||||
doc.keywords.substring(end, doc.keywords.length) +
|
||||
'</i>';
|
||||
return this.getHit(doc, formattedTitle);
|
||||
}
|
||||
|
||||
getContentHit(doc, position) {
|
||||
const start = position[0];
|
||||
const end = position[0] + position[1];
|
||||
let previewStart = start;
|
||||
let previewEnd = end;
|
||||
let ellipsesBefore = true;
|
||||
let ellipsesAfter = true;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const nextSpace = doc.content.lastIndexOf(' ', previewStart - 2);
|
||||
const nextDot = doc.content.lastIndexOf('.', previewStart - 2);
|
||||
if (nextDot > 0 && nextDot > nextSpace) {
|
||||
previewStart = nextDot + 1;
|
||||
ellipsesBefore = false;
|
||||
break;
|
||||
}
|
||||
if (nextSpace < 0) {
|
||||
previewStart = 0;
|
||||
ellipsesBefore = false;
|
||||
break;
|
||||
}
|
||||
previewStart = nextSpace + 1;
|
||||
}
|
||||
search(input) {
|
||||
return new Promise((resolve, rej) => {
|
||||
const results = this.getLunrResult(input);
|
||||
const hits = [];
|
||||
results.length > 5 && (results.length = 5);
|
||||
this.titleHitsRes = []
|
||||
this.contentHitsRes = []
|
||||
results.forEach(result => {
|
||||
const doc = this.searchDocs[result.ref];
|
||||
const { metadata } = result.matchData;
|
||||
for (let i in metadata) {
|
||||
if (metadata[i].title) {
|
||||
if (!this.titleHitsRes.includes(result.ref)) {
|
||||
const position = metadata[i].title.position[0]
|
||||
hits.push(this.getTitleHit(doc, position, input.length));
|
||||
this.titleHitsRes.push(result.ref);
|
||||
}
|
||||
} else if (metadata[i].content) {
|
||||
const position = metadata[i].content.position[0]
|
||||
hits.push(this.getContentHit(doc, position))
|
||||
} else if (metadata[i].keywords) {
|
||||
const position = metadata[i].keywords.position[0]
|
||||
hits.push(this.getKeywordHit(doc, position, input.length));
|
||||
this.titleHitsRes.push(result.ref);
|
||||
}
|
||||
}
|
||||
});
|
||||
hits.length > 5 && (hits.length = 5);
|
||||
resolve(hits);
|
||||
});
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const nextSpace = doc.content.indexOf(' ', previewEnd + 1);
|
||||
const nextDot = doc.content.indexOf('.', previewEnd + 1);
|
||||
if (nextDot > 0 && nextDot < nextSpace) {
|
||||
previewEnd = nextDot;
|
||||
ellipsesAfter = false;
|
||||
break;
|
||||
}
|
||||
if (nextSpace < 0) {
|
||||
previewEnd = doc.content.length;
|
||||
ellipsesAfter = false;
|
||||
break;
|
||||
}
|
||||
previewEnd = nextSpace;
|
||||
}
|
||||
let preview = doc.content.substring(previewStart, start);
|
||||
if (ellipsesBefore) {
|
||||
preview = '... ' + preview;
|
||||
}
|
||||
preview += '<span class="algolia-docsearch-suggestion--highlight">' + doc.content.substring(start, end) + '</span>';
|
||||
preview += doc.content.substring(end, previewEnd);
|
||||
if (ellipsesAfter) {
|
||||
preview += ' ...';
|
||||
}
|
||||
return this.getHit(doc, null, preview);
|
||||
}
|
||||
search(input) {
|
||||
return new Promise((resolve, rej) => {
|
||||
const results = this.getLunrResult(input);
|
||||
const hits = [];
|
||||
results.length > 5 && (results.length = 5);
|
||||
this.titleHitsRes = [];
|
||||
this.contentHitsRes = [];
|
||||
results.forEach((result) => {
|
||||
const doc = this.searchDocs[result.ref];
|
||||
const { metadata } = result.matchData;
|
||||
for (let i in metadata) {
|
||||
if (metadata[i].title) {
|
||||
if (!this.titleHitsRes.includes(result.ref)) {
|
||||
const position = metadata[i].title.position[0];
|
||||
hits.push(this.getTitleHit(doc, position, input.length));
|
||||
this.titleHitsRes.push(result.ref);
|
||||
}
|
||||
} else if (metadata[i].content) {
|
||||
const position = metadata[i].content.position[0];
|
||||
hits.push(this.getContentHit(doc, position));
|
||||
} else if (metadata[i].keywords) {
|
||||
const position = metadata[i].keywords.position[0];
|
||||
hits.push(this.getKeywordHit(doc, position, input.length));
|
||||
this.titleHitsRes.push(result.ref);
|
||||
}
|
||||
}
|
||||
});
|
||||
hits.length > 5 && (hits.length = 5);
|
||||
resolve(hits);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default LunrSearchAdapter;
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import $ from "autocomplete.js/zepto";
|
||||
import $ from 'autocomplete.js/zepto';
|
||||
|
||||
const utils = {
|
||||
/*
|
||||
* Move the content of an object key one level higher.
|
||||
* eg.
|
||||
* {
|
||||
* name: 'My name',
|
||||
* hierarchy: {
|
||||
* lvl0: 'Foo',
|
||||
* lvl1: 'Bar'
|
||||
* }
|
||||
* }
|
||||
* Will be converted to
|
||||
* {
|
||||
* name: 'My name',
|
||||
* lvl0: 'Foo',
|
||||
* lvl1: 'Bar'
|
||||
* }
|
||||
* @param {Object} object Main object
|
||||
* @param {String} property Main object key to move up
|
||||
* @return {Object}
|
||||
* @throws Error when key is not an attribute of Object or is not an object itself
|
||||
*/
|
||||
* Move the content of an object key one level higher.
|
||||
* eg.
|
||||
* {
|
||||
* name: 'My name',
|
||||
* hierarchy: {
|
||||
* lvl0: 'Foo',
|
||||
* lvl1: 'Bar'
|
||||
* }
|
||||
* }
|
||||
* Will be converted to
|
||||
* {
|
||||
* name: 'My name',
|
||||
* lvl0: 'Foo',
|
||||
* lvl1: 'Bar'
|
||||
* }
|
||||
* @param {Object} object Main object
|
||||
* @param {String} property Main object key to move up
|
||||
* @return {Object}
|
||||
* @throws Error when key is not an attribute of Object or is not an object itself
|
||||
*/
|
||||
mergeKeyWithParent(object, property) {
|
||||
if (object[property] === undefined) {
|
||||
return object;
|
||||
@@ -34,36 +34,36 @@ const utils = {
|
||||
return newObject;
|
||||
},
|
||||
/*
|
||||
* Group all objects of a collection by the value of the specified attribute
|
||||
* If the attribute is a string, use the lowercase form.
|
||||
*
|
||||
* eg.
|
||||
* groupBy([
|
||||
* {name: 'Tim', category: 'dev'},
|
||||
* {name: 'Vincent', category: 'dev'},
|
||||
* {name: 'Ben', category: 'sales'},
|
||||
* {name: 'Jeremy', category: 'sales'},
|
||||
* {name: 'AlexS', category: 'dev'},
|
||||
* {name: 'AlexK', category: 'sales'}
|
||||
* ], 'category');
|
||||
* =>
|
||||
* {
|
||||
* 'devs': [
|
||||
* {name: 'Tim', category: 'dev'},
|
||||
* {name: 'Vincent', category: 'dev'},
|
||||
* {name: 'AlexS', category: 'dev'}
|
||||
* ],
|
||||
* 'sales': [
|
||||
* {name: 'Ben', category: 'sales'},
|
||||
* {name: 'Jeremy', category: 'sales'},
|
||||
* {name: 'AlexK', category: 'sales'}
|
||||
* ]
|
||||
* }
|
||||
* @param {array} collection Array of objects to group
|
||||
* @param {String} property The attribute on which apply the grouping
|
||||
* @return {array}
|
||||
* @throws Error when one of the element does not have the specified property
|
||||
*/
|
||||
* Group all objects of a collection by the value of the specified attribute
|
||||
* If the attribute is a string, use the lowercase form.
|
||||
*
|
||||
* eg.
|
||||
* groupBy([
|
||||
* {name: 'Tim', category: 'dev'},
|
||||
* {name: 'Vincent', category: 'dev'},
|
||||
* {name: 'Ben', category: 'sales'},
|
||||
* {name: 'Jeremy', category: 'sales'},
|
||||
* {name: 'AlexS', category: 'dev'},
|
||||
* {name: 'AlexK', category: 'sales'}
|
||||
* ], 'category');
|
||||
* =>
|
||||
* {
|
||||
* 'devs': [
|
||||
* {name: 'Tim', category: 'dev'},
|
||||
* {name: 'Vincent', category: 'dev'},
|
||||
* {name: 'AlexS', category: 'dev'}
|
||||
* ],
|
||||
* 'sales': [
|
||||
* {name: 'Ben', category: 'sales'},
|
||||
* {name: 'Jeremy', category: 'sales'},
|
||||
* {name: 'AlexK', category: 'sales'}
|
||||
* ]
|
||||
* }
|
||||
* @param {array} collection Array of objects to group
|
||||
* @param {String} property The attribute on which apply the grouping
|
||||
* @return {array}
|
||||
* @throws Error when one of the element does not have the specified property
|
||||
*/
|
||||
groupBy(collection, property) {
|
||||
const newCollection = {};
|
||||
$.each(collection, (index, item) => {
|
||||
@@ -84,94 +84,94 @@ const utils = {
|
||||
return newCollection;
|
||||
},
|
||||
/*
|
||||
* Return an array of all the values of the specified object
|
||||
* eg.
|
||||
* values({
|
||||
* foo: 42,
|
||||
* bar: true,
|
||||
* baz: 'yep'
|
||||
* })
|
||||
* =>
|
||||
* [42, true, yep]
|
||||
* @param {object} object Object to extract values from
|
||||
* @return {array}
|
||||
*/
|
||||
* Return an array of all the values of the specified object
|
||||
* eg.
|
||||
* values({
|
||||
* foo: 42,
|
||||
* bar: true,
|
||||
* baz: 'yep'
|
||||
* })
|
||||
* =>
|
||||
* [42, true, yep]
|
||||
* @param {object} object Object to extract values from
|
||||
* @return {array}
|
||||
*/
|
||||
values(object) {
|
||||
return Object.keys(object).map(key => object[key]);
|
||||
return Object.keys(object).map((key) => object[key]);
|
||||
},
|
||||
/*
|
||||
* Flattens an array
|
||||
* eg.
|
||||
* flatten([1, 2, [3, 4], [5, 6]])
|
||||
* =>
|
||||
* [1, 2, 3, 4, 5, 6]
|
||||
* @param {array} array Array to flatten
|
||||
* @return {array}
|
||||
*/
|
||||
* Flattens an array
|
||||
* eg.
|
||||
* flatten([1, 2, [3, 4], [5, 6]])
|
||||
* =>
|
||||
* [1, 2, 3, 4, 5, 6]
|
||||
* @param {array} array Array to flatten
|
||||
* @return {array}
|
||||
*/
|
||||
flatten(array) {
|
||||
const results = [];
|
||||
array.forEach(value => {
|
||||
array.forEach((value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
results.push(value);
|
||||
return;
|
||||
}
|
||||
value.forEach(subvalue => {
|
||||
value.forEach((subvalue) => {
|
||||
results.push(subvalue);
|
||||
});
|
||||
});
|
||||
return results;
|
||||
},
|
||||
/*
|
||||
* Flatten all values of an object into an array, marking each first element of
|
||||
* each group with a specific flag
|
||||
* eg.
|
||||
* flattenAndFlagFirst({
|
||||
* 'devs': [
|
||||
* {name: 'Tim', category: 'dev'},
|
||||
* {name: 'Vincent', category: 'dev'},
|
||||
* {name: 'AlexS', category: 'dev'}
|
||||
* ],
|
||||
* 'sales': [
|
||||
* {name: 'Ben', category: 'sales'},
|
||||
* {name: 'Jeremy', category: 'sales'},
|
||||
* {name: 'AlexK', category: 'sales'}
|
||||
* ]
|
||||
* , 'isTop');
|
||||
* =>
|
||||
* [
|
||||
* {name: 'Tim', category: 'dev', isTop: true},
|
||||
* {name: 'Vincent', category: 'dev', isTop: false},
|
||||
* {name: 'AlexS', category: 'dev', isTop: false},
|
||||
* {name: 'Ben', category: 'sales', isTop: true},
|
||||
* {name: 'Jeremy', category: 'sales', isTop: false},
|
||||
* {name: 'AlexK', category: 'sales', isTop: false}
|
||||
* ]
|
||||
* @param {object} object Object to flatten
|
||||
* @param {string} flag Flag to set to true on first element of each group
|
||||
* @return {array}
|
||||
*/
|
||||
* Flatten all values of an object into an array, marking each first element of
|
||||
* each group with a specific flag
|
||||
* eg.
|
||||
* flattenAndFlagFirst({
|
||||
* 'devs': [
|
||||
* {name: 'Tim', category: 'dev'},
|
||||
* {name: 'Vincent', category: 'dev'},
|
||||
* {name: 'AlexS', category: 'dev'}
|
||||
* ],
|
||||
* 'sales': [
|
||||
* {name: 'Ben', category: 'sales'},
|
||||
* {name: 'Jeremy', category: 'sales'},
|
||||
* {name: 'AlexK', category: 'sales'}
|
||||
* ]
|
||||
* , 'isTop');
|
||||
* =>
|
||||
* [
|
||||
* {name: 'Tim', category: 'dev', isTop: true},
|
||||
* {name: 'Vincent', category: 'dev', isTop: false},
|
||||
* {name: 'AlexS', category: 'dev', isTop: false},
|
||||
* {name: 'Ben', category: 'sales', isTop: true},
|
||||
* {name: 'Jeremy', category: 'sales', isTop: false},
|
||||
* {name: 'AlexK', category: 'sales', isTop: false}
|
||||
* ]
|
||||
* @param {object} object Object to flatten
|
||||
* @param {string} flag Flag to set to true on first element of each group
|
||||
* @return {array}
|
||||
*/
|
||||
flattenAndFlagFirst(object, flag) {
|
||||
const values = this.values(object).map(collection =>
|
||||
const values = this.values(object).map((collection) =>
|
||||
collection.map((item, index) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
item[flag] = index === 0;
|
||||
return item;
|
||||
})
|
||||
}),
|
||||
);
|
||||
return this.flatten(values);
|
||||
},
|
||||
/*
|
||||
* Removes all empty strings, null, false and undefined elements array
|
||||
* eg.
|
||||
* compact([42, false, null, undefined, '', [], 'foo']);
|
||||
* =>
|
||||
* [42, [], 'foo']
|
||||
* @param {array} array Array to compact
|
||||
* @return {array}
|
||||
*/
|
||||
* Removes all empty strings, null, false and undefined elements array
|
||||
* eg.
|
||||
* compact([42, false, null, undefined, '', [], 'foo']);
|
||||
* =>
|
||||
* [42, [], 'foo']
|
||||
* @param {array} array Array to compact
|
||||
* @return {array}
|
||||
*/
|
||||
compact(array) {
|
||||
const results = [];
|
||||
array.forEach(value => {
|
||||
array.forEach((value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
@@ -239,11 +239,7 @@ const utils = {
|
||||
* @return {string}
|
||||
**/
|
||||
getSnippetedValue(object, property) {
|
||||
if (
|
||||
!object._snippetResult ||
|
||||
!object._snippetResult[property] ||
|
||||
!object._snippetResult[property].value
|
||||
) {
|
||||
if (!object._snippetResult || !object._snippetResult[property] || !object._snippetResult[property].value) {
|
||||
return object[property];
|
||||
}
|
||||
let snippet = object._snippetResult[property].value;
|
||||
@@ -257,11 +253,11 @@ const utils = {
|
||||
return snippet;
|
||||
},
|
||||
/*
|
||||
* Deep clone an object.
|
||||
* Note: This will not clone functions and dates
|
||||
* @param {object} object Object to clone
|
||||
* @return {object}
|
||||
*/
|
||||
* Deep clone an object.
|
||||
* Note: This will not clone functions and dates
|
||||
* @param {object} object Object to clone
|
||||
* @return {object}
|
||||
*/
|
||||
deepClone(object) {
|
||||
return JSON.parse(JSON.stringify(object));
|
||||
},
|
||||
|
||||
BIN
docs/static/img/immich-screenshots.png
vendored
Normal file
BIN
docs/static/img/immich-screenshots.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/static/img/immich-screenshots.webp
vendored
BIN
docs/static/img/immich-screenshots.webp
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB |
@@ -4,25 +4,25 @@ module.exports = {
|
||||
corePlugins: {
|
||||
preflight: false, // disable Tailwind's reset
|
||||
},
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}", "../docs/**/*.mdx"], // my markdown stuff is in ../docs, not /src
|
||||
darkMode: ["class", '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}', '../docs/**/*.mdx'], // my markdown stuff is in ../docs, not /src
|
||||
darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Light Theme
|
||||
"immich-primary": "#4250af",
|
||||
"immich-bg": "white",
|
||||
"immich-fg": "black",
|
||||
"immich-gray": "#F6F6F4",
|
||||
'immich-primary': '#4250af',
|
||||
'immich-bg': 'white',
|
||||
'immich-fg': 'black',
|
||||
'immich-gray': '#F6F6F4',
|
||||
|
||||
// Dark Theme
|
||||
"immich-dark-primary": "#adcbfa",
|
||||
"immich-dark-bg": "black",
|
||||
"immich-dark-fg": "#e5e7eb",
|
||||
"immich-dark-gray": "#212121",
|
||||
'immich-dark-primary': '#adcbfa',
|
||||
'immich-dark-bg': 'black',
|
||||
'immich-dark-fg': '#e5e7eb',
|
||||
'immich-dark-gray': '#212121',
|
||||
},
|
||||
fontFamily: {
|
||||
"immich-title": ["Snowburst One", "cursive"],
|
||||
'immich-title': ['Snowburst One', 'cursive'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
venv/
|
||||
venv/
|
||||
*.zip
|
||||
*.onnx
|
||||
170
machine-learning/.gitignore
vendored
170
machine-learning/.gitignore
vendored
@@ -1,4 +1,172 @@
|
||||
*.zip
|
||||
*.onnx
|
||||
upload/
|
||||
venv/
|
||||
__pycache__/
|
||||
model-cache/
|
||||
model-cache/
|
||||
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
|
||||
*.onnx
|
||||
*.zip
|
||||
@@ -1,13 +1,15 @@
|
||||
FROM python:3.10 as builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=true
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=true
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
RUN /opt/venv/bin/pip install --pre torch -f https://download.pytorch.org/whl/nightly/cpu/torch_nightly.html
|
||||
RUN /opt/venv/bin/pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece flask Pillow gunicorn
|
||||
RUN /opt/venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
RUN /opt/venv/bin/pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece fastapi Pillow uvicorn[standard]
|
||||
RUN /opt/venv/bin/pip install --no-deps sentence-transformers
|
||||
# Facial Recognition Stuff
|
||||
RUN /opt/venv/bin/pip install insightface onnxruntime
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
@@ -16,12 +18,12 @@ ENV NODE_ENV=production
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
ENV TRANSFORMERS_CACHE=/cache \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["gunicorn", "src.main:server"]
|
||||
ENV PYTHONPATH=`pwd`
|
||||
CMD ["python", "src/main.py"]
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""
|
||||
Gunicorn configuration options.
|
||||
https://docs.gunicorn.org/en/stable/settings.html
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
# Set the bind address based on the env
|
||||
port = os.getenv("MACHINE_LEARNING_PORT") or "3003"
|
||||
listen_ip = os.getenv("MACHINE_LEARNING_IP") or "0.0.0.0"
|
||||
bind = [f"{listen_ip}:{port}"]
|
||||
|
||||
# Preload the Flask app / models etc. before starting the server
|
||||
preload_app = True
|
||||
|
||||
# Logging settings - log to stdout and set log level
|
||||
accesslog = "-"
|
||||
loglevel = os.getenv("MACHINE_LEARNING_LOG_LEVEL") or "info"
|
||||
|
||||
# Worker settings
|
||||
# ----------------------
|
||||
# It is important these are chosen carefully as per
|
||||
# https://pythonspeed.com/articles/gunicorn-in-docker/
|
||||
# Otherwise we get workers failing to respond to heartbeat checks,
|
||||
# especially as requests take a long time to complete.
|
||||
workers = 2
|
||||
threads = 4
|
||||
worker_tmp_dir = "/dev/shm"
|
||||
timeout = 60
|
||||
@@ -1,58 +1,108 @@
|
||||
import os
|
||||
from flask import Flask, request
|
||||
import numpy as np
|
||||
import cv2 as cv
|
||||
import uvicorn
|
||||
|
||||
from insightface.app import FaceAnalysis
|
||||
from transformers import pipeline
|
||||
from sentence_transformers import SentenceTransformer, util
|
||||
from PIL import Image
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
is_dev = os.getenv('NODE_ENV') == 'development'
|
||||
server_port = os.getenv('MACHINE_LEARNING_PORT', 3003)
|
||||
server_host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
|
||||
|
||||
classification_model = os.getenv('MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
|
||||
class MlRequestBody(BaseModel):
|
||||
thumbnailPath: str
|
||||
|
||||
|
||||
class ClipRequestBody(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
classification_model = os.getenv(
|
||||
'MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
|
||||
object_model = os.getenv('MACHINE_LEARNING_OBJECT_MODEL', 'hustvl/yolos-tiny')
|
||||
clip_image_model = os.getenv('MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
|
||||
clip_text_model = os.getenv('MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
|
||||
clip_image_model = os.getenv(
|
||||
'MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
|
||||
clip_text_model = os.getenv(
|
||||
'MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
|
||||
facial_recognition_model = os.getenv(
|
||||
'MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL', 'buffalo_l')
|
||||
|
||||
cache_folder = os.getenv('MACHINE_LEARNING_CACHE_FOLDER', '/cache')
|
||||
|
||||
_model_cache = {}
|
||||
def _get_model(model, task=None):
|
||||
global _model_cache
|
||||
key = '|'.join([model, str(task)])
|
||||
if key not in _model_cache:
|
||||
if task:
|
||||
_model_cache[key] = pipeline(model=model, task=task)
|
||||
else:
|
||||
_model_cache[key] = SentenceTransformer(model)
|
||||
return _model_cache[key]
|
||||
|
||||
server = Flask(__name__)
|
||||
app = FastAPI()
|
||||
|
||||
@server.route("/ping")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Immich ML"}
|
||||
|
||||
|
||||
@app.get("/ping")
|
||||
def ping():
|
||||
return "pong"
|
||||
|
||||
@server.route("/object-detection/detect-object", methods=['POST'])
|
||||
def object_detection():
|
||||
|
||||
@app.post("/object-detection/detect-object", status_code=200)
|
||||
def object_detection(payload: MlRequestBody):
|
||||
model = _get_model(object_model, 'object-detection')
|
||||
assetPath = request.json['thumbnailPath']
|
||||
return run_engine(model, assetPath), 200
|
||||
assetPath = payload.thumbnailPath
|
||||
return run_engine(model, assetPath)
|
||||
|
||||
@server.route("/image-classifier/tag-image", methods=['POST'])
|
||||
def image_classification():
|
||||
|
||||
@app.post("/image-classifier/tag-image", status_code=200)
|
||||
def image_classification(payload: MlRequestBody):
|
||||
model = _get_model(classification_model, 'image-classification')
|
||||
assetPath = request.json['thumbnailPath']
|
||||
return run_engine(model, assetPath), 200
|
||||
assetPath = payload.thumbnailPath
|
||||
return run_engine(model, assetPath)
|
||||
|
||||
@server.route("/sentence-transformer/encode-image", methods=['POST'])
|
||||
def clip_encode_image():
|
||||
|
||||
@app.post("/sentence-transformer/encode-image", status_code=200)
|
||||
def clip_encode_image(payload: MlRequestBody):
|
||||
model = _get_model(clip_image_model)
|
||||
assetPath = request.json['thumbnailPath']
|
||||
return model.encode(Image.open(assetPath)).tolist(), 200
|
||||
assetPath = payload.thumbnailPath
|
||||
return model.encode(Image.open(assetPath)).tolist()
|
||||
|
||||
@server.route("/sentence-transformer/encode-text", methods=['POST'])
|
||||
def clip_encode_text():
|
||||
|
||||
@app.post("/sentence-transformer/encode-text", status_code=200)
|
||||
def clip_encode_text(payload: ClipRequestBody):
|
||||
model = _get_model(clip_text_model)
|
||||
text = request.json['text']
|
||||
return model.encode(text).tolist(), 200
|
||||
text = payload.text
|
||||
return model.encode(text).tolist()
|
||||
|
||||
|
||||
@app.post("/facial-recognition/detect-faces", status_code=200)
|
||||
def facial_recognition(payload: MlRequestBody):
|
||||
model = _get_model(facial_recognition_model, 'facial-recognition')
|
||||
assetPath = payload.thumbnailPath
|
||||
img = cv.imread(assetPath)
|
||||
height, width, _ = img.shape
|
||||
results = []
|
||||
faces = model.get(img)
|
||||
for face in faces:
|
||||
if face.det_score < 0.7:
|
||||
continue
|
||||
x1, y1, x2, y2 = face.bbox
|
||||
# min face size as percent of original image
|
||||
# if (x2 - x1) / width < 0.03 or (y2 - y1) / height < 0.05:
|
||||
# continue
|
||||
results.append({
|
||||
"imageWidth": width,
|
||||
"imageHeight": height,
|
||||
"boundingBox": {
|
||||
"x1": round(x1),
|
||||
"y1": round(y1),
|
||||
"x2": round(x2),
|
||||
"y2": round(y2),
|
||||
},
|
||||
"score": face.det_score.item(),
|
||||
"embedding": face.normed_embedding.tolist()
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def run_engine(engine, path):
|
||||
result = []
|
||||
@@ -69,5 +119,27 @@ def run_engine(engine, path):
|
||||
return result
|
||||
|
||||
|
||||
def _get_model(model, task=None):
|
||||
global _model_cache
|
||||
key = '|'.join([model, str(task)])
|
||||
if key not in _model_cache:
|
||||
if task:
|
||||
if task == 'facial-recognition':
|
||||
face_model = FaceAnalysis(
|
||||
name=model, root=cache_folder, allowed_modules=["detection", "recognition"])
|
||||
face_model.prepare(ctx_id=0, det_size=(640, 640))
|
||||
_model_cache[key] = face_model
|
||||
else:
|
||||
_model_cache[key] = pipeline(model=model, task=task)
|
||||
else:
|
||||
_model_cache[key] = SentenceTransformer(
|
||||
model, cache_folder=cache_folder)
|
||||
return _model_cache[key]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server.run(debug=is_dev, host=server_host, port=server_port)
|
||||
host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
|
||||
port = int(os.getenv('MACHINE_LEARNING_PORT', 3003))
|
||||
is_dev = os.getenv('NODE_ENV') == 'development'
|
||||
|
||||
uvicorn.run("main:app", host=host, port=port, reload=is_dev, workers=1)
|
||||
|
||||
@@ -61,26 +61,17 @@ fi
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
npm --prefix server version $SERVER_PUMP
|
||||
npm --prefix server run api:generate
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
|
||||
fi
|
||||
|
||||
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package.json
|
||||
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package-lock.json
|
||||
sed -i "s/\"version\": \"$CURRENT_SERVER\",$/\"version\": \"$NEXT_SERVER\",/" server/immich-openapi-specs.json
|
||||
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
||||
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||
|
||||
# OpenApi Generated Files
|
||||
sed -i "s/API version: $CURRENT_SERVER,$/API version: $NEXT_SERVER/" mobile/openapi/README.md
|
||||
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/api.ts
|
||||
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/base.ts
|
||||
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/common.ts
|
||||
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/configuration.ts
|
||||
sed -i "s/OpenAPI document: $CURRENT_SERVER,$/OpenAPI document: $NEXT_SERVER/" web/src/api/open-api/index.ts
|
||||
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"flutterSdkVersion": "3.7.0",
|
||||
"flutterSdkVersion": "3.10.0",
|
||||
"flavors": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ if (flutterVersionName == null) {
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
@@ -86,4 +87,6 @@ dependencies {
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
|
||||
@GlideModule
|
||||
class AppGlideModule : AppGlideModule()
|
||||
@@ -1,15 +1,16 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.kotlin_version = '1.8.20'
|
||||
ext.work_version = '2.7.1'
|
||||
ext.concurrent_version = '1.1.0'
|
||||
ext.guava_version = '31.0.1-android'
|
||||
ext.glide_version = '4.14.2'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||
classpath 'com.android.tools.build:gradle:7.4.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
@@ -29,6 +30,6 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 77,
|
||||
"android.injected.version.name" => "1.54.0",
|
||||
"android.injected.version.code" => 79,
|
||||
"android.injected.version.name" => "1.56.0",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
* Fixes white navigation bar in Android 8.
|
||||
* Add bottom safe area to video player controls.
|
||||
* Fix null check operator on null value.
|
||||
* Add troubleshooting toggle.
|
||||
* Configurable log level.
|
||||
* Consistent handling of DateTime in SyncService.
|
||||
* Fix asset removal edge cases.
|
||||
* Archive feature on mobile.
|
||||
* Add AssetState and proper asset updating.
|
||||
* Video player disposes early.
|
||||
@@ -0,0 +1 @@
|
||||
* Minor UI improvement
|
||||
@@ -5,17 +5,19 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000285">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00032">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="168.230955">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="29.247439">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="43.975952">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="22.794249">
|
||||
|
||||
<failure message="/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:229:in `chdir' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:42:in `block (2 levels) in parsing_binding' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/lane.rb:33:in `call' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:45:in `chdir' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:45:in `execute' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/bin/fastlane:23:in `<top (required)>' /usr/local/Cellar/fastlane/2.212.2/libexec/bin/fastlane:25:in `load' /usr/local/Cellar/fastlane/2.212.2/libexec/bin/fastlane:25:in `<main>' Google Api Error: Invalid request - APK specifies a version code that has already been used." />
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#Fri Jun 23 08:50:38 CEST 2017
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
|
||||
distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2
|
||||
@@ -21,6 +21,7 @@
|
||||
"asset_list_layout_settings_group_by": "Group assets by",
|
||||
"asset_list_layout_settings_group_by_month": "Month",
|
||||
"asset_list_layout_settings_group_by_month_day": "Month + day",
|
||||
"asset_list_layout_settings_group_automatically": "Automatic",
|
||||
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||
"asset_list_settings_title": "Photo Grid",
|
||||
"backup_album_selection_page_albums_device": "Albums on device ({})",
|
||||
@@ -112,6 +113,7 @@
|
||||
"control_bottom_app_bar_delete": "Delete",
|
||||
"control_bottom_app_bar_favorite": "Favorite",
|
||||
"control_bottom_app_bar_archive": "Archive",
|
||||
"control_bottom_app_bar_unarchive": "Unarchive",
|
||||
"control_bottom_app_bar_share": "Share",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"create_shared_album_page_create": "Create",
|
||||
@@ -135,6 +137,7 @@
|
||||
"experimental_settings_subtitle": "Use at your own risk!",
|
||||
"experimental_settings_title": "Experimental",
|
||||
"favorites_page_title": "Favorites",
|
||||
"favorites_page_no_favorites": "No favorite assets found",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||
@@ -272,5 +275,6 @@
|
||||
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"description_input_hint_text": "Add description...",
|
||||
"archive_page_title": "Archive ({})"
|
||||
}
|
||||
"archive_page_title": "Archive ({})",
|
||||
"archive_page_no_archived_assets": "No archived assets found"
|
||||
}
|
||||
@@ -35,6 +35,14 @@ target 'Runner' do
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.generated_projects.each do |project|
|
||||
project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ DEPENDENCIES:
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
|
||||
@@ -100,7 +100,7 @@ EXTERNAL SOURCES:
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/ios"
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
path_provider_ios:
|
||||
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||
permission_handler_apple:
|
||||
@@ -110,7 +110,7 @@ EXTERNAL SOURCES:
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
@@ -129,7 +129,7 @@ SPEC CHECKSUMS:
|
||||
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
image_picker_ios: 58b9c4269cb176f89acea5e5d043c9358f2d25f8
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
|
||||
@@ -145,6 +145,6 @@ SPEC CHECKSUMS:
|
||||
video_player_avfoundation: 6d971a232d72e6ee25368378d48a079dea01f1cf
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
|
||||
PODFILE CHECKSUM: 0606648e8a9ecd5a59eafa5ab3187b45a9004a28
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
@@ -378,7 +379,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 95;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -514,7 +515,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 95;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -542,7 +543,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
CURRENT_PROJECT_VERSION = 95;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.50.0</string>
|
||||
<string>1.55.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>88</string>
|
||||
<string>95</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.54.0"
|
||||
version_number: "1.56.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,34 +5,29 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000301">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000282">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="1.613972">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.815995">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.887872">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.927419">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.53884">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.464698">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="64.096001">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="66.988561">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="6.017821">
|
||||
|
||||
<failure message="/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:30:in `block (2 levels) in parsing_binding' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane.rb:33:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/bin/fastlane:23:in `<top (required)>' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `load' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `<main>' Error uploading ipa file: [Application Loader Error Output]: Error uploading '/var/folders/lp/myp2frzj00g93mcbnz6cd8900000gn/T/279a4279-ff7e-48d3-b6fd-1ee020a5b0a9.ipa'.
|
||||
<failure message="/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:27:in `block (2 levels) in parsing_binding' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane.rb:33:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/bin/fastlane:23:in `<top (required)>' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `load' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `<main>' Error packaging up the application" />
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -2,47 +2,20 @@ import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class AssetSelectionPageResult {
|
||||
final Set<Asset> selectedNewAsset;
|
||||
final Set<Asset> selectedAdditionalAsset;
|
||||
final bool isAlbumExist;
|
||||
final Set<Asset> selectedAssets;
|
||||
|
||||
AssetSelectionPageResult({
|
||||
required this.selectedNewAsset,
|
||||
required this.selectedAdditionalAsset,
|
||||
required this.isAlbumExist,
|
||||
required this.selectedAssets,
|
||||
});
|
||||
|
||||
AssetSelectionPageResult copyWith({
|
||||
Set<Asset>? selectedNewAsset,
|
||||
Set<Asset>? selectedAdditionalAsset,
|
||||
bool? isAlbumExist,
|
||||
}) {
|
||||
return AssetSelectionPageResult(
|
||||
selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset,
|
||||
selectedAdditionalAsset:
|
||||
selectedAdditionalAsset ?? this.selectedAdditionalAsset,
|
||||
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AssetSelectionPageResult(selectedNewAsset: $selectedNewAsset, selectedAdditionalAsset: $selectedAdditionalAsset, isAlbumExist: $isAlbumExist)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
final setEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other is AssetSelectionPageResult &&
|
||||
setEquals(other.selectedNewAsset, selectedNewAsset) &&
|
||||
setEquals(other.selectedAdditionalAsset, selectedAdditionalAsset) &&
|
||||
other.isAlbumExist == isAlbumExist;
|
||||
setEquals(other.selectedAssets, selectedAssets);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
selectedNewAsset.hashCode ^
|
||||
selectedAdditionalAsset.hashCode ^
|
||||
isAlbumExist.hashCode;
|
||||
int get hashCode => selectedAssets.hashCode;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -9,50 +10,38 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||
AlbumNotifier(this._albumService, this._db) : super([]);
|
||||
AlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
|
||||
query.findAll().then((value) => state = value);
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
}
|
||||
final AlbumService _albumService;
|
||||
final Isar _db;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<void> getAllAlbums() async {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
List<Album> albums = await _db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(me.isarId))
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
await Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
albums = await _db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(me.isarId))
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
}
|
||||
Future<void> getAllAlbums() => Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
return _albumService.deleteAlbum(album);
|
||||
}
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) async {
|
||||
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
|
||||
if (album != null) {
|
||||
state = [...state, album];
|
||||
}
|
||||
return album;
|
||||
) =>
|
||||
_albumService.createAlbum(albumTitle, assets, []);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
|
||||
final albumProvider =
|
||||
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
|
||||
return AlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
|
||||
AssetSelectionNotifier()
|
||||
: super(
|
||||
AssetSelectionState(
|
||||
selectedNewAssetsForAlbum: {},
|
||||
selectedMonths: {},
|
||||
selectedAdditionalAssetsForAlbum: {},
|
||||
selectedAssetsInAlbumViewer: {},
|
||||
isAlbumExist: false,
|
||||
isMultiselectEnable: false,
|
||||
),
|
||||
);
|
||||
|
||||
void setIsAlbumExist(bool isAlbumExist) {
|
||||
state = state.copyWith(isAlbumExist: isAlbumExist);
|
||||
}
|
||||
|
||||
void removeAssetsInMonth(
|
||||
String removedMonth,
|
||||
List<Asset> assetsInMonth,
|
||||
) {
|
||||
Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum;
|
||||
Set<String> currentMonthList = state.selectedMonths;
|
||||
|
||||
currentMonthList
|
||||
.removeWhere((selectedMonth) => selectedMonth == removedMonth);
|
||||
|
||||
for (Asset asset in assetsInMonth) {
|
||||
currentAssetList.removeWhere((e) => e.id == asset.id);
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
selectedNewAssetsForAlbum: currentAssetList,
|
||||
selectedMonths: currentMonthList,
|
||||
);
|
||||
}
|
||||
|
||||
void addAdditionalAssets(List<Asset> assets) {
|
||||
state = state.copyWith(
|
||||
selectedAdditionalAssetsForAlbum: {
|
||||
...state.selectedAdditionalAssetsForAlbum,
|
||||
...assets
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) {
|
||||
state = state.copyWith(
|
||||
selectedMonths: {...state.selectedMonths, month},
|
||||
selectedNewAssetsForAlbum: {
|
||||
...state.selectedNewAssetsForAlbum,
|
||||
...assetsInMonth
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void addNewAssets(Iterable<Asset> assets) {
|
||||
state = state.copyWith(
|
||||
selectedNewAssetsForAlbum: {
|
||||
...state.selectedNewAssetsForAlbum,
|
||||
...assets
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void removeSelectedNewAssets(List<Asset> assets) {
|
||||
Set<Asset> currentList = state.selectedNewAssetsForAlbum;
|
||||
|
||||
for (Asset asset in assets) {
|
||||
currentList.removeWhere((e) => e.id == asset.id);
|
||||
}
|
||||
|
||||
state = state.copyWith(selectedNewAssetsForAlbum: currentList);
|
||||
}
|
||||
|
||||
void removeSelectedAdditionalAssets(List<Asset> assets) {
|
||||
Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum;
|
||||
|
||||
for (Asset asset in assets) {
|
||||
currentList.removeWhere((e) => e.id == asset.id);
|
||||
}
|
||||
|
||||
state = state.copyWith(selectedAdditionalAssetsForAlbum: currentList);
|
||||
}
|
||||
|
||||
void removeAll() {
|
||||
state = state.copyWith(
|
||||
selectedNewAssetsForAlbum: {},
|
||||
selectedMonths: {},
|
||||
selectedAdditionalAssetsForAlbum: {},
|
||||
selectedAssetsInAlbumViewer: {},
|
||||
isAlbumExist: false,
|
||||
);
|
||||
}
|
||||
|
||||
void enableMultiselection() {
|
||||
state = state.copyWith(isMultiselectEnable: true);
|
||||
}
|
||||
|
||||
void disableMultiselection() {
|
||||
state = state.copyWith(
|
||||
isMultiselectEnable: false,
|
||||
selectedAssetsInAlbumViewer: {},
|
||||
);
|
||||
}
|
||||
|
||||
void addAssetsInAlbumViewer(List<Asset> assets) {
|
||||
state = state.copyWith(
|
||||
selectedAssetsInAlbumViewer: {
|
||||
...state.selectedAssetsInAlbumViewer,
|
||||
...assets
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void removeAssetsInAlbumViewer(List<Asset> assets) {
|
||||
Set<Asset> currentList = state.selectedAssetsInAlbumViewer;
|
||||
|
||||
for (Asset asset in assets) {
|
||||
currentList.removeWhere((e) => e.id == asset.id);
|
||||
}
|
||||
|
||||
state = state.copyWith(selectedAssetsInAlbumViewer: currentList);
|
||||
}
|
||||
}
|
||||
|
||||
final assetSelectionProvider =
|
||||
StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) {
|
||||
return AssetSelectionNotifier();
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
@@ -9,10 +11,14 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
SharedAlbumNotifier(this._albumService, this._db) : super([]);
|
||||
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
query.findAll().then((value) => state = value);
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
}
|
||||
|
||||
final AlbumService _albumService;
|
||||
final Isar _db;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
String albumName,
|
||||
@@ -20,46 +26,21 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Iterable<User> sharedUsers,
|
||||
) async {
|
||||
try {
|
||||
final Album? newAlbum = await _albumService.createAlbum(
|
||||
return await _albumService.createAlbum(
|
||||
albumName,
|
||||
assets,
|
||||
sharedUsers,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
state = [...state, newAlbum];
|
||||
return newAlbum;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getAllSharedAlbums() async {
|
||||
var albums = await _db.albums
|
||||
.filter()
|
||||
.sharedEqualTo(true)
|
||||
.sortByCreatedAtDesc()
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
await _albumService.refreshRemoteAlbums(isShared: true);
|
||||
albums = await _db.albums
|
||||
.filter()
|
||||
.sharedEqualTo(true)
|
||||
.sortByCreatedAtDesc()
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
}
|
||||
Future<void> getAllSharedAlbums() =>
|
||||
_albumService.refreshRemoteAlbums(isShared: true);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
return _albumService.deleteAlbum(album);
|
||||
}
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
var res = await _albumService.leaveAlbum(album);
|
||||
@@ -75,10 +56,16 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final sharedAlbumProvider =
|
||||
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
@@ -86,10 +73,15 @@ final sharedAlbumProvider =
|
||||
});
|
||||
|
||||
final sharedAlbumDetailProvider =
|
||||
FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async {
|
||||
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
|
||||
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
|
||||
|
||||
final Album? a = await sharedAlbumService.getAlbumDetail(albumId);
|
||||
await a?.loadSortedAssets();
|
||||
return a;
|
||||
await for (final a in sharedAlbumService.watchAlbum(albumId)) {
|
||||
if (a == null) {
|
||||
throw Exception("Album with ID=$albumId does not exist anymore!");
|
||||
}
|
||||
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
|
||||
yield a;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -214,8 +214,9 @@ class AlbumService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumDetail(int albumId) {
|
||||
return _db.albums.get(albumId);
|
||||
Stream<Album?> watchAlbum(int albumId) async* {
|
||||
yield await _db.albums.get(albumId);
|
||||
yield* _db.albums.watchObject(albumId);
|
||||
}
|
||||
|
||||
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
@@ -110,12 +109,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
TextStyle(color: Theme.of(context).primaryColor),
|
||||
),
|
||||
onPressed: () {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeAll();
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addNewAssets(assets);
|
||||
AutoRouter.of(context).push(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
|
||||
@@ -9,12 +9,14 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final void Function(Album) onAddToAlbum;
|
||||
final bool enabled;
|
||||
|
||||
const AddToAlbumSliverList({
|
||||
Key? key,
|
||||
required this.onAddToAlbum,
|
||||
required this.albums,
|
||||
required this.sharedAlbums,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -28,14 +30,14 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ExpansionTile(
|
||||
title: Text('common_shared'.tr()),
|
||||
title: Text('common_shared'.tr()),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
leading: const Icon(Icons.group),
|
||||
children: sharedAlbums
|
||||
.map(
|
||||
(album) => AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => onAddToAlbum(album),
|
||||
onTap: enabled ? () => onAddToAlbum(album) : () {},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -48,7 +50,7 @@ class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
final album = albums[offset];
|
||||
return AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => onAddToAlbum(album),
|
||||
onTap: enabled ? () => onAddToAlbum(album) : () {},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,29 +5,32 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
class AlbumViewerAppbar extends HookConsumerWidget
|
||||
implements PreferredSizeWidget {
|
||||
const AlbumViewerAppbar({
|
||||
Key? key,
|
||||
required this.album,
|
||||
required this.userId,
|
||||
required this.selected,
|
||||
required this.selectionDisabled,
|
||||
required this.titleFocusNode,
|
||||
}) : super(key: key);
|
||||
|
||||
final Album album;
|
||||
final String userId;
|
||||
final Set<Asset> selected;
|
||||
final void Function() selectionDisabled;
|
||||
final FocusNode titleFocusNode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isMultiSelectionEnable =
|
||||
ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||
final selectedAssetsInAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
|
||||
@@ -85,12 +88,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
bool isSuccess =
|
||||
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
|
||||
album,
|
||||
selectedAssetsInAlbum,
|
||||
selected,
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
Navigator.pop(context);
|
||||
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||
selectionDisabled();
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(sharedAlbumDetailProvider(album.id));
|
||||
} else {
|
||||
@@ -107,7 +110,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
}
|
||||
|
||||
buildBottomSheetActionButton() {
|
||||
if (isMultiSelectionEnable) {
|
||||
if (selected.isNotEmpty) {
|
||||
if (album.ownerId == userId) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.delete_sweep_rounded),
|
||||
@@ -162,11 +165,9 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
}
|
||||
|
||||
buildLeadingButton() {
|
||||
if (isMultiSelectionEnable) {
|
||||
if (selected.isNotEmpty) {
|
||||
return IconButton(
|
||||
onPressed: () => ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.disableMultiselection(),
|
||||
onPressed: selectionDisabled,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
splashRadius: 25,
|
||||
);
|
||||
@@ -201,9 +202,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
return AppBar(
|
||||
elevation: 0,
|
||||
leading: buildLeadingButton(),
|
||||
title: isMultiSelectionEnable
|
||||
? Text('${selectedAssetsInAlbum.length}')
|
||||
: null,
|
||||
title: selected.isNotEmpty ? Text('${selected.length}') : null,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
if (album.isRemote)
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/utils/storage_indicator.dart';
|
||||
|
||||
class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final List<Asset> assetList;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
const AlbumViewerThumbnail({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedAssetsInAlbumViewer =
|
||||
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
final isMultiSelectionEnable =
|
||||
ref.watch(assetSelectionProvider).isMultiselectEnable;
|
||||
final isFavorite = ref.watch(favoriteProvider).contains(asset.id);
|
||||
|
||||
viewAsset() {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
asset: asset,
|
||||
assetList: assetList,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BoxBorder drawBorderColor() {
|
||||
if (selectedAssetsInAlbumViewer.contains(asset)) {
|
||||
return Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
);
|
||||
} else {
|
||||
return const Border();
|
||||
}
|
||||
}
|
||||
|
||||
enableMultiSelection() {
|
||||
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addAssetsInAlbumViewer([asset]);
|
||||
}
|
||||
|
||||
disableMultiSelection() {
|
||||
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||
}
|
||||
|
||||
buildVideoLabel() {
|
||||
return Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetStoreLocationIcon() {
|
||||
return Positioned(
|
||||
right: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
storageIcon(asset),
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetFavoriteIcon() {
|
||||
return const Positioned(
|
||||
left: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
Icons.star,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetSelectionIcon() {
|
||||
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
|
||||
|
||||
return Positioned(
|
||||
left: 10,
|
||||
top: 5,
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildThumbnailImage() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: ImmichImage(asset, width: 300, height: 300),
|
||||
);
|
||||
}
|
||||
|
||||
handleSelectionGesture() {
|
||||
if (selectedAssetsInAlbumViewer.contains(asset)) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeAssetsInAlbumViewer([asset]);
|
||||
|
||||
if (selectedAssetsInAlbumViewer.isEmpty) {
|
||||
disableMultiSelection();
|
||||
}
|
||||
} else {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addAssetsInAlbumViewer([asset]);
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isMultiSelectionEnable ? handleSelectionGesture : viewAsset,
|
||||
onLongPress: enableMultiSelection,
|
||||
child: Stack(
|
||||
children: [
|
||||
buildThumbnailImage(),
|
||||
if (isFavorite) buildAssetFavoriteIcon(),
|
||||
if (showStorageIndicator) buildAssetStoreLocationIcon(),
|
||||
if (!asset.isImage) buildVideoLabel(),
|
||||
if (isMultiSelectionEnable) buildAssetSelectionIcon(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class AssetGridByMonth extends HookConsumerWidget {
|
||||
final List<Asset> assetGroup;
|
||||
const AssetGridByMonth({Key? key, required this.assetGroup})
|
||||
: super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return SelectionThumbnailImage(asset: assetGroup[index]);
|
||||
},
|
||||
childCount: assetGroup.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class MonthGroupTitle extends HookConsumerWidget {
|
||||
final String month;
|
||||
final List<Asset> assetGroup;
|
||||
|
||||
const MonthGroupTitle({
|
||||
Key? key,
|
||||
required this.month,
|
||||
required this.assetGroup,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths;
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
handleTitleIconClick() {
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
if (isAlbumExist) {
|
||||
if (selectedDateGroup.contains(month)) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeAssetsInMonth(month, []);
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeSelectedAdditionalAssets(assetGroup);
|
||||
} else {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addAllAssetsInMonth(month, []);
|
||||
|
||||
// Deep clone assetGroup
|
||||
var assetGroupWithNewItems = [...assetGroup];
|
||||
|
||||
for (var selectedAsset in selectedAssets) {
|
||||
assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id);
|
||||
}
|
||||
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addAdditionalAssets(assetGroupWithNewItems);
|
||||
}
|
||||
} else {
|
||||
if (selectedDateGroup.contains(month)) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeAssetsInMonth(month, assetGroup);
|
||||
} else {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addAllAssetsInMonth(month, assetGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSimplifiedMonth() {
|
||||
var monthAndYear = month.split(',');
|
||||
var yearText = monthAndYear[1].trim();
|
||||
var monthText = monthAndYear[0].trim();
|
||||
var currentYear = DateTime.now().year.toString();
|
||||
|
||||
if (yearText == currentYear) {
|
||||
return monthText;
|
||||
} else {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 29.0,
|
||||
bottom: 29.0,
|
||||
left: 14.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: handleTitleIconClick,
|
||||
child: selectedDateGroup.contains(month)
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.circle_outlined,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: handleTitleIconClick,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
getSimplifiedMonth(),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
|
||||
class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
|
||||
const SelectionThumbnailImage({Key? key, required this.asset})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var selectedAsset =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
var newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
Widget buildSelectionIcon(Asset asset) {
|
||||
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
||||
var isNewlySelected =
|
||||
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
|
||||
|
||||
if (isSelected && !isAlbumExist) {
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
} else if (isSelected && isAlbumExist) {
|
||||
return const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color.fromARGB(255, 233, 233, 233),
|
||||
);
|
||||
} else if (isNewlySelected && isAlbumExist) {
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
} else {
|
||||
return const Icon(
|
||||
Icons.circle_outlined,
|
||||
color: Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxBorder drawBorderColor() {
|
||||
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
||||
var isNewlySelected =
|
||||
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
|
||||
|
||||
if (isSelected && !isAlbumExist) {
|
||||
return Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
);
|
||||
} else if (isSelected && isAlbumExist) {
|
||||
return Border.all(
|
||||
color: const Color.fromARGB(255, 190, 190, 190),
|
||||
width: 10,
|
||||
);
|
||||
} else if (isNewlySelected && isAlbumExist) {
|
||||
return Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
);
|
||||
}
|
||||
return const Border();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
var isSelected =
|
||||
selectedAsset.map((item) => item.id).contains(asset.id);
|
||||
var isNewlySelected =
|
||||
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
|
||||
|
||||
if (isAlbumExist) {
|
||||
// Operation for existing album
|
||||
if (!isSelected) {
|
||||
if (isNewlySelected) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeSelectedAdditionalAssets([asset]);
|
||||
} else {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addAdditionalAssets([asset]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Operation for new album
|
||||
if (isSelected) {
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeSelectedNewAssets([asset]);
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: ImmichImage(asset, width: 150, height: 150),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
if (!asset.isImage)
|
||||
Positioned(
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,18 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.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/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
@@ -32,33 +29,51 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
FocusNode titleFocusNode = useFocusNode();
|
||||
ScrollController scrollController = useScrollController();
|
||||
final album = ref.watch(sharedAlbumDetailProvider(albumId));
|
||||
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
final selection = useState<Set<Asset>>({});
|
||||
final multiSelectEnabled = useState(false);
|
||||
bool? isTop;
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
if (multiSelectEnabled.value) {
|
||||
selection.value = {};
|
||||
multiSelectEnabled.value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void selectionListener(bool active, Set<Asset> selected) {
|
||||
selection.value = selected;
|
||||
multiSelectEnabled.value = selected.isNotEmpty;
|
||||
}
|
||||
|
||||
void disableSelection() {
|
||||
selection.value = {};
|
||||
multiSelectEnabled.value = false;
|
||||
}
|
||||
|
||||
/// 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(Album albumInfo) async {
|
||||
if (albumInfo.assets.isNotEmpty == true) {
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets(
|
||||
albumInfo.assets,
|
||||
);
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
|
||||
|
||||
AssetSelectionPageResult? returnPayload = await AutoRouter.of(context)
|
||||
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
AssetSelectionPageResult? returnPayload =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: albumInfo.assets,
|
||||
isNewAlbum: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (returnPayload != null) {
|
||||
// Check if there is new assets add
|
||||
if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
|
||||
if (returnPayload.selectedAssets.isNotEmpty) {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
var addAssetsResult =
|
||||
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
|
||||
returnPayload.selectedAdditionalAsset,
|
||||
returnPayload.selectedAssets,
|
||||
albumInfo,
|
||||
);
|
||||
|
||||
@@ -70,10 +85,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
} else {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,13 +102,38 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
.addAdditionalUserToAlbum(sharedUserIds, album);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
ref.invalidate(sharedAlbumDetailProvider(album.id));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildControlButton(Album album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
AlbumActionOutlinedButton(
|
||||
iconData: Icons.add_photo_alternate_outlined,
|
||||
onPressed: () => onAddPhotosPressed(album),
|
||||
labelText: "share_add_photos".tr(),
|
||||
),
|
||||
if (userId == album.ownerId)
|
||||
AlbumActionOutlinedButton(
|
||||
iconData: Icons.person_add_alt_rounded,
|
||||
onPressed: () => onAddUsersPressed(album),
|
||||
labelText: "album_viewer_page_share_add_users".tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTitle(Album album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
||||
@@ -146,171 +182,104 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Widget buildHeader(Album album) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitle(album),
|
||||
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
|
||||
if (album.shared)
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 18,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
child: Image.asset(
|
||||
'assets/immich-logo-no-outline.png',
|
||||
),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitle(album),
|
||||
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
|
||||
if (album.shared)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: ((context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 18,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
child: Image.asset(
|
||||
'assets/immich-logo-no-outline.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
itemCount: album.sharedUsers.length,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImageGrid(Album album) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final bool showStorageIndicator =
|
||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||
|
||||
if (album.sortedAssets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(
|
||||
asset: album.sortedAssets[index],
|
||||
assetList: album.sortedAssets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
);
|
||||
},
|
||||
childCount: album.assetCount,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
|
||||
Widget buildControlButton(Album album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
AlbumActionOutlinedButton(
|
||||
iconData: Icons.add_photo_alternate_outlined,
|
||||
onPressed: () => onAddPhotosPressed(album),
|
||||
labelText: "share_add_photos".tr(),
|
||||
),
|
||||
if (userId == album.ownerId)
|
||||
AlbumActionOutlinedButton(
|
||||
iconData: Icons.person_add_alt_rounded,
|
||||
onPressed: () => onAddUsersPressed(album),
|
||||
labelText: "album_viewer_page_share_add_users".tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
final isMultiselectEnable = ref
|
||||
.read(assetSelectionProvider)
|
||||
.selectedAssetsInAlbumViewer
|
||||
.isNotEmpty;
|
||||
if (isMultiselectEnable) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Widget buildBody(Album album) {
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
titleFocusNode.unfocus();
|
||||
},
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
buildHeader(album),
|
||||
if (album.isRemote)
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildControlButton(album),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverSafeArea(
|
||||
sliver: buildImageGrid(album),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
itemCount: album.sharedUsers.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final scroll = ScrollController();
|
||||
|
||||
return Scaffold(
|
||||
appBar: album.when(
|
||||
data: (Album? data) {
|
||||
if (data != null) {
|
||||
return AlbumViewerAppbar(
|
||||
album: data,
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
error: (e, _) => null,
|
||||
loading: () => null,
|
||||
data: (data) => AlbumViewerAppbar(
|
||||
titleFocusNode: titleFocusNode,
|
||||
album: data,
|
||||
userId: userId,
|
||||
selected: selection.value,
|
||||
selectionDisabled: disableSelection,
|
||||
),
|
||||
error: (error, stackTrace) => AppBar(title: const Text("Error")),
|
||||
loading: () => AppBar(),
|
||||
),
|
||||
body: album.when(
|
||||
data: (albumInfo) => albumInfo != null
|
||||
? buildBody(albumInfo)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
data: (data) => WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
titleFocusNode.unfocus();
|
||||
},
|
||||
child: NestedScrollView(
|
||||
controller: scroll,
|
||||
floatHeaderSlivers: true,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverToBoxAdapter(child: buildHeader(data)),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildControlButton(data),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
body: ImmichAssetGrid(
|
||||
renderList: data.renderList,
|
||||
listener: selectionListener,
|
||||
selectionActive: multiSelectEnabled.value,
|
||||
showMultiSelectIndicator: false,
|
||||
visibleItemsListener: (start, end) {
|
||||
final top = start.index == 0 && start.itemLeadingEdge == 0.0;
|
||||
if (top != isTop) {
|
||||
isTop = top;
|
||||
scroll.animateTo(
|
||||
top
|
||||
? scroll.position.minScrollExtent
|
||||
: scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: top ? Curves.easeOut : Curves.easeIn,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
error: (e, _) => Center(child: Text("Error loading album info $e")),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(child: Text("Error loading album info!\n$e")),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
|
||||
@@ -4,54 +4,42 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/asset_grid_by_month.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/month_group_title.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
|
||||
class AssetSelectionPage extends HookConsumerWidget {
|
||||
const AssetSelectionPage({Key? key}) : super(key: key);
|
||||
const AssetSelectionPage({
|
||||
Key? key,
|
||||
required this.existingAssets,
|
||||
this.isNewAlbum = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final Set<Asset> existingAssets;
|
||||
final bool isNewAlbum;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider);
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
|
||||
List<Widget> imageGridGroup = [];
|
||||
final renderList = ref.watch(remoteAssetsProvider);
|
||||
final selected = useState<Set<Asset>>(existingAssets);
|
||||
final selectionEnabledHook = useState(true);
|
||||
|
||||
String buildAssetCountText() {
|
||||
if (isAlbumExist) {
|
||||
return (selectedAssets.length + newAssetsForAlbum.length).toString();
|
||||
} else {
|
||||
return selectedAssets.length.toString();
|
||||
}
|
||||
return selected.value.length.toString();
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
assetGroupMonthYear.forEach((monthYear, assetGroup) {
|
||||
imageGridGroup
|
||||
.add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
|
||||
imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup));
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [...imageGridGroup],
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget buildBody(RenderList renderList) {
|
||||
return ImmichAssetGrid(
|
||||
renderList: renderList,
|
||||
listener: (active, assets) {
|
||||
selectionEnabledHook.value = active;
|
||||
selected.value = assets;
|
||||
},
|
||||
selectionActive: true,
|
||||
preselectedAssets: isNewAlbum ? selected.value : existingAssets,
|
||||
canDeselect: isNewAlbum,
|
||||
showMultiSelectIndicator: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,11 +49,10 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
AutoRouter.of(context).pop(null);
|
||||
AutoRouter.of(context).popForced(null);
|
||||
},
|
||||
),
|
||||
title: selectedAssets.isEmpty
|
||||
title: selected.value.isEmpty
|
||||
? const Text(
|
||||
'share_add_photos',
|
||||
style: TextStyle(fontSize: 18),
|
||||
@@ -76,16 +63,13 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
if ((!isAlbumExist && selectedAssets.isNotEmpty) ||
|
||||
(isAlbumExist && newAssetsForAlbum.isNotEmpty))
|
||||
if (selected.value.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
var payload = AssetSelectionPageResult(
|
||||
isAlbumExist: isAlbumExist,
|
||||
selectedAdditionalAsset: newAssetsForAlbum,
|
||||
selectedNewAsset: selectedAssets,
|
||||
);
|
||||
AutoRouter.of(context).pop(payload);
|
||||
var payload =
|
||||
AssetSelectionPageResult(selectedAssets: selected.value);
|
||||
AutoRouter.of(context)
|
||||
.popForced<AssetSelectionPageResult>(payload);
|
||||
},
|
||||
child: const Text(
|
||||
"share_add",
|
||||
@@ -94,7 +78,13 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: buildBody(),
|
||||
body: renderList.when(
|
||||
data: (data) => buildBody(data),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Text(error.toString()),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
|
||||
@@ -31,12 +30,15 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
final albumTitleTextFieldFocusNode = useFocusNode();
|
||||
final isAlbumTitleTextFieldFocus = useState(false);
|
||||
final isAlbumTitleEmpty = useState(true);
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final selectedAssets = useState<Set<Asset>>(const {});
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showSelectUserPage() {
|
||||
AutoRouter.of(context).push(const SelectUserForSharingRoute());
|
||||
showSelectUserPage() async {
|
||||
final bool? ok = await AutoRouter.of(context)
|
||||
.push<bool?>(SelectUserForSharingRoute(assets: selectedAssets.value));
|
||||
if (ok == true) {
|
||||
selectedAssets.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
void onBackgroundTapped() {
|
||||
@@ -52,13 +54,17 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
onSelectPhotosButtonPressed() async {
|
||||
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
|
||||
|
||||
AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
|
||||
.push<AssetSelectionPageResult?>(const AssetSelectionRoute());
|
||||
|
||||
AssetSelectionPageResult? selectedAsset =
|
||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||
AssetSelectionRoute(
|
||||
existingAssets: selectedAssets.value,
|
||||
isNewAlbum: true,
|
||||
),
|
||||
);
|
||||
if (selectedAsset == null) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
selectedAssets.value = const {};
|
||||
} else {
|
||||
selectedAssets.value = selectedAsset.selectedAssets;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +84,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildTitle() {
|
||||
if (selectedAssets.isEmpty) {
|
||||
if (selectedAssets.value.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 200, left: 18),
|
||||
@@ -97,7 +103,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildSelectPhotosButton() {
|
||||
if (selectedAssets.isEmpty) {
|
||||
if (selectedAssets.value.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16, left: 18, right: 18),
|
||||
@@ -158,7 +164,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildSelectedImageGrid() {
|
||||
if (selectedAssets.isNotEmpty) {
|
||||
if (selectedAssets.value.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
sliver: SliverGrid(
|
||||
@@ -172,11 +178,11 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
return GestureDetector(
|
||||
onTap: onBackgroundTapped,
|
||||
child: SharedAlbumThumbnailImage(
|
||||
asset: selectedAssets.elementAt(index),
|
||||
asset: selectedAssets.value.elementAt(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: selectedAssets.length,
|
||||
childCount: selectedAssets.value.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -188,12 +194,12 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
createNonSharedAlbum() async {
|
||||
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
|
||||
ref.watch(albumTitleProvider),
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
|
||||
selectedAssets.value,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
selectedAssets.value = {};
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
AutoRouter.of(context).replace(AlbumViewerRoute(albumId: newAlbum.id));
|
||||
@@ -207,7 +213,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
selectedAssets.value = {};
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
@@ -237,7 +243,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
if (!isSharedAlbum)
|
||||
TextButton(
|
||||
onPressed: albumTitleController.text.isNotEmpty &&
|
||||
selectedAssets.isNotEmpty
|
||||
selectedAssets.value.isNotEmpty
|
||||
? createNonSharedAlbum
|
||||
: null,
|
||||
child: Text(
|
||||
@@ -264,7 +270,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
buildTitleInputField(),
|
||||
if (selectedAssets.isNotEmpty) buildControlButton(),
|
||||
if (selectedAssets.value.isNotEmpty) buildControlButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -221,7 +221,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
buildLibraryNavButton(
|
||||
"library_page_favorites".tr(), Icons.star_border, () {
|
||||
"library_page_favorites".tr(), Icons.favorite_border, () {
|
||||
AutoRouter.of(context).navigate(const FavoritesRoute());
|
||||
}),
|
||||
const SizedBox(width: 12.0),
|
||||
|
||||
@@ -4,15 +4,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
const SelectUserForSharingPage({Key? key}) : super(key: key);
|
||||
const SelectUserForSharingPage({Key? key, required this.assets})
|
||||
: super(key: key);
|
||||
|
||||
final Set<Asset> assets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -24,15 +27,15 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
var newAlbum =
|
||||
await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
|
||||
ref.watch(albumTitleProvider),
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
|
||||
assets,
|
||||
sharedUsersList.value,
|
||||
);
|
||||
|
||||
if (newAlbum != null) {
|
||||
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
// ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
|
||||
AutoRouter.of(context).pop(true);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
}
|
||||
|
||||
@@ -85,15 +85,15 @@ class SharingPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: isOwner
|
||||
? const Text(
|
||||
'Owned',
|
||||
style: TextStyle(
|
||||
? Text(
|
||||
'album_thumbnail_owned'.tr(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
)
|
||||
: album.ownerName != null
|
||||
? Text(
|
||||
'Shared by ${album.ownerName!}',
|
||||
'album_thumbnail_shared_by'.tr(args: [album.ownerName!]),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
|
||||
@@ -1,55 +1,25 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class ArchiveSelectionNotifier extends StateNotifier<Set<int>> {
|
||||
ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) {
|
||||
state = db.assets
|
||||
.filter()
|
||||
.isArchivedEqualTo(true)
|
||||
.findAllSync()
|
||||
.map((e) => e.id)
|
||||
.toSet();
|
||||
final archiveProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||
.isArchivedEqualTo(true)
|
||||
.sortByFileCreatedAt();
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
await for (final _ in query.watchLazy()) {
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
}
|
||||
|
||||
final Isar db;
|
||||
final AssetNotifier assetNotifier;
|
||||
|
||||
void _setArchiveForAssetId(int id, bool archive) {
|
||||
if (!archive) {
|
||||
state = state.difference({id});
|
||||
} else {
|
||||
state = state.union({id});
|
||||
}
|
||||
}
|
||||
|
||||
bool _isArchive(int id) {
|
||||
return state.contains(id);
|
||||
}
|
||||
|
||||
Future<void> toggleArchive(Asset asset) async {
|
||||
if (asset.storage == AssetState.local) return;
|
||||
|
||||
_setArchiveForAssetId(asset.id, !_isArchive(asset.id));
|
||||
|
||||
await assetNotifier.toggleArchive(
|
||||
[asset],
|
||||
state.contains(asset.id),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addToArchives(Iterable<Asset> assets) {
|
||||
state = state.union(assets.map((a) => a.id).toSet());
|
||||
return assetNotifier.toggleArchive(assets, true);
|
||||
}
|
||||
}
|
||||
|
||||
final archiveProvider =
|
||||
StateNotifierProvider<ArchiveSelectionNotifier, Set<int>>((ref) {
|
||||
return ArchiveSelectionNotifier(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(assetProvider.notifier),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,46 +1,25 @@
|
||||
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' hide Store;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class ArchivePage extends HookConsumerWidget {
|
||||
const ArchivePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(me.isarId)
|
||||
.isArchivedEqualTo(true);
|
||||
final stream = query.watch();
|
||||
final archivedAssets = useState<List<Asset>>([]);
|
||||
final archivedAssets = ref.watch(archiveProvider);
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selection = useState(<Asset>{});
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
query.findAll().then((value) => archivedAssets.value = value);
|
||||
final subscription = stream.listen((e) {
|
||||
archivedAssets.value = e;
|
||||
});
|
||||
// Cancel the subscription when the widget is disposed
|
||||
return subscription.cancel;
|
||||
},
|
||||
[],
|
||||
);
|
||||
final processing = useState(false);
|
||||
|
||||
void selectionListener(
|
||||
bool multiselect,
|
||||
@@ -50,7 +29,7 @@ class ArchivePage extends HookConsumerWidget {
|
||||
selection.value = selectedAssets;
|
||||
}
|
||||
|
||||
AppBar buildAppBar() {
|
||||
AppBar buildAppBar(String count) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
@@ -60,7 +39,7 @@ class ArchivePage extends HookConsumerWidget {
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'archive_page_title',
|
||||
).tr(args: [archivedAssets.value.length.toString()]),
|
||||
).tr(args: [count]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,26 +59,38 @@ class ArchivePage extends HookConsumerWidget {
|
||||
leading: const Icon(
|
||||
Icons.unarchive_rounded,
|
||||
),
|
||||
title:
|
||||
const Text("Unarchive", style: TextStyle(fontSize: 14)),
|
||||
onTap: () {
|
||||
if (selection.value.isNotEmpty) {
|
||||
ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleArchive(selection.value, false);
|
||||
title: Text(
|
||||
'control_bottom_app_bar_unarchive'.tr(),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
onTap: processing.value
|
||||
? null
|
||||
: () async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleArchive(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
|
||||
final assetOrAssets =
|
||||
selection.value.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
|
||||
selectionEnabledHook.value = false;
|
||||
},
|
||||
final assetOrAssets = selection.value.length > 1
|
||||
? 'assets'
|
||||
: 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -109,17 +100,33 @@ class ArchivePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
ImmichAssetGrid(
|
||||
assets: archivedAssets.value,
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar()
|
||||
],
|
||||
return archivedAssets.when(
|
||||
loading: () => Scaffold(
|
||||
appBar: buildAppBar("?"),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stackTrace) => Scaffold(
|
||||
appBar: buildAppBar("Error"),
|
||||
body: Center(child: Text(error.toString())),
|
||||
),
|
||||
data: (data) => Scaffold(
|
||||
appBar: buildAppBar(data.totalAssets.toString()),
|
||||
body: data.isEmpty
|
||||
? Center(
|
||||
child: Text('archive_page_no_archived_assets'.tr()),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
ImmichAssetGrid(
|
||||
renderList: data,
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar(),
|
||||
if (processing.value)
|
||||
const Center(child: ImmichLoadingIndicator())
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
class RequestDownloadAssetInfo {
|
||||
final String assetId;
|
||||
final String deviceId;
|
||||
|
||||
RequestDownloadAssetInfo(this.assetId, this.deviceId);
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
final renderListProvider =
|
||||
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final layout = AssetGridLayoutParameters(
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
settings.getSetting(AppSettingsEnum.dynamicLayout),
|
||||
return RenderList.fromAssets(
|
||||
assets,
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
);
|
||||
|
||||
return RenderList.fromAssets(assets, layout);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExifBottomSheet extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
@@ -42,19 +43,25 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
),
|
||||
zoom: 16.0,
|
||||
),
|
||||
layers: [
|
||||
TileLayerOptions(
|
||||
nonRotatedChildren: [
|
||||
RichAttributionWidget(
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
attributionBuilder: (_) {
|
||||
return const Text(
|
||||
"© OpenStreetMap",
|
||||
style: TextStyle(fontSize: 10),
|
||||
);
|
||||
},
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
),
|
||||
MarkerLayerOptions(
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
@@ -88,9 +95,9 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildDragHeader() {
|
||||
return Column(
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
children: [
|
||||
SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
|
||||
@@ -21,7 +21,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback? onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isFavorite;
|
||||
|
||||
@@ -31,11 +31,9 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
|
||||
Widget buildFavoriteButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onFavorite();
|
||||
},
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.star : Icons.star_border,
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -12,9 +12,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
@@ -32,16 +30,16 @@ import 'package:openapi/api.dart' as api;
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
final List<Asset> assetList;
|
||||
final Asset asset;
|
||||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final int initialIndex;
|
||||
|
||||
GalleryViewerPage({
|
||||
super.key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
}) : controller = PageController(initialPage: assetList.indexOf(asset));
|
||||
|
||||
Asset? assetDetail;
|
||||
required this.initialIndex,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
}) : controller = PageController(initialPage: initialIndex);
|
||||
|
||||
final PageController controller;
|
||||
|
||||
@@ -52,11 +50,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
|
||||
final isZoomed = useState<bool>(false);
|
||||
final showAppBar = useState<bool>(true);
|
||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
late Offset localPosition;
|
||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
final watchedAsset = ref.watch(assetDetailProvider(currentAsset));
|
||||
|
||||
Asset asset() => watchedAsset.value ?? currentAsset;
|
||||
|
||||
showAppBar.addListener(() {
|
||||
// Change to and from immersive mode, hiding navigation and app bar
|
||||
@@ -79,16 +81,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
void toggleFavorite(Asset asset) {
|
||||
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
|
||||
}
|
||||
|
||||
void getAssetExif() async {
|
||||
assetDetail = assetList[indexOfAsset.value];
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
.loadExif(assetList[indexOfAsset.value]);
|
||||
}
|
||||
void toggleFavorite(Asset asset) => ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite([asset], !asset.isFavorite);
|
||||
|
||||
/// Thumbnail image of a remote asset. Required asset.isRemote
|
||||
ImageProvider remoteThumbnailImageProvider(
|
||||
@@ -138,8 +133,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
if (index < assetList.length && index >= 0) {
|
||||
final asset = assetList[index];
|
||||
if (index < totalAssets && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
|
||||
if (asset.isLocal) {
|
||||
// Preload the local asset
|
||||
@@ -193,13 +188,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
if (ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) {
|
||||
return AdvancedBottomSheet(assetDetail: assetDetail!);
|
||||
return AdvancedBottomSheet(assetDetail: asset());
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: ExifBottomSheet(asset: assetDetail!),
|
||||
child: ExifBottomSheet(asset: asset()),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -211,7 +206,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () {
|
||||
if (assetList.length == 1) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
@@ -221,7 +216,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
assetList.remove(deleteAsset);
|
||||
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
|
||||
},
|
||||
);
|
||||
@@ -267,9 +261,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
shareAsset() {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.shareAsset(assetList[indexOfAsset.value], context);
|
||||
ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context);
|
||||
}
|
||||
|
||||
handleArchive(Asset asset) {
|
||||
@@ -291,30 +283,21 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: TopControlAppBar(
|
||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
||||
asset: assetList[indexOfAsset.value],
|
||||
isFavorite: ref.watch(favoriteProvider).contains(
|
||||
assetList[indexOfAsset.value].id,
|
||||
),
|
||||
onMoreInfoPressed: () {
|
||||
showInfo();
|
||||
},
|
||||
onFavorite: () {
|
||||
toggleFavorite(assetList[indexOfAsset.value]);
|
||||
},
|
||||
onDownloadPressed: assetList[indexOfAsset.value].storage ==
|
||||
AssetState.local
|
||||
asset: asset(),
|
||||
isFavorite: asset().isFavorite,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null,
|
||||
onDownloadPressed: asset().storage == AssetState.local
|
||||
? null
|
||||
: () {
|
||||
: () =>
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
|
||||
assetList[indexOfAsset.value],
|
||||
asset(),
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
onToggleMotionVideo: (() {
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
onAddToAlbumPressed: () =>
|
||||
addToAlbum(assetList[indexOfAsset.value]),
|
||||
onAddToAlbumPressed: () => addToAlbum(asset()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -324,8 +307,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final show = (showAppBar.value || // onTap has the final say
|
||||
(showAppBar.value && !isZoomed.value)) &&
|
||||
!isPlayingVideo.value;
|
||||
final currentAsset = assetList[indexOfAsset.value];
|
||||
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
@@ -338,22 +319,26 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.ios_share_rounded),
|
||||
label: 'Share',
|
||||
tooltip: 'Share',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: currentAsset.isArchived
|
||||
? const Icon(Icons.unarchive_rounded)
|
||||
: const Icon(Icons.archive_outlined),
|
||||
label: 'Archive',
|
||||
tooltip: 'Archive',
|
||||
icon: const Icon(Icons.ios_share_rounded),
|
||||
label: 'control_bottom_app_bar_share'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.delete_outline),
|
||||
label: 'Delete',
|
||||
tooltip: 'Delete',
|
||||
asset().isArchived
|
||||
? BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.unarchive_rounded),
|
||||
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
)
|
||||
: BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.archive_outlined),
|
||||
label: 'control_bottom_app_bar_archive'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: 'control_bottom_app_bar_delete'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
||||
),
|
||||
],
|
||||
onTap: (index) {
|
||||
@@ -362,10 +347,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
shareAsset();
|
||||
break;
|
||||
case 1:
|
||||
handleArchive(assetList[indexOfAsset.value]);
|
||||
handleArchive(asset());
|
||||
break;
|
||||
case 2:
|
||||
handleDelete(assetList[indexOfAsset.value]);
|
||||
handleDelete(asset());
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -395,33 +380,33 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: assetList.length,
|
||||
itemCount: totalAssets,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (indexOfAsset.value < value) {
|
||||
if (currentIndex.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
indexOfAsset.value = value;
|
||||
currentIndex.value = value;
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
loadingBuilder: isLoadPreview.value
|
||||
? (context, event) {
|
||||
final asset = assetList[indexOfAsset.value];
|
||||
if (!asset.isLocal) {
|
||||
final a = asset();
|
||||
if (!a.isLocal) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
@@ -440,11 +425,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
// makes sense if the original is loaded in the builder
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
@@ -458,30 +443,30 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
} else {
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(asset),
|
||||
image: localThumbnailImageProvider(a),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
builder: (context, index) {
|
||||
getAssetExif();
|
||||
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||
final asset = loadAsset(index);
|
||||
if (asset.isImage && !isPlayingMotionVideo.value) {
|
||||
// Show photo
|
||||
final ImageProvider provider;
|
||||
if (assetList[index].isLocal) {
|
||||
provider = localImageProvider(assetList[index]);
|
||||
if (asset.isLocal) {
|
||||
provider = localImageProvider(asset);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
provider = originalImageProvider(assetList[index]);
|
||||
provider = originalImageProvider(asset);
|
||||
} else if (isLoadPreview.value) {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
} else {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
}
|
||||
@@ -495,13 +480,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
showAppBar.value = !showAppBar.value,
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
tag: asset.id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
assetList[indexOfAsset.value],
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
@@ -512,7 +497,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
tag: asset.id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
@@ -522,7 +507,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
child: VideoViewerPage(
|
||||
onPlaying: () => isPlayingVideo.value = true,
|
||||
onPaused: () => isPlayingVideo.value = false,
|
||||
asset: assetList[index],
|
||||
asset: asset,
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
|
||||
@@ -1,68 +1,25 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class FavoriteSelectionNotifier extends StateNotifier<Set<int>> {
|
||||
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
|
||||
state = assetsState.allAssets
|
||||
.where((asset) => asset.isFavorite)
|
||||
.map((asset) => asset.id)
|
||||
.toSet();
|
||||
final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||
.isFavoriteEqualTo(true)
|
||||
.sortByFileCreatedAt();
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
await for (final _ in query.watchLazy()) {
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
}
|
||||
|
||||
final AssetsState assetsState;
|
||||
final AssetNotifier assetNotifier;
|
||||
|
||||
void _setFavoriteForAssetId(int id, bool favorite) {
|
||||
if (!favorite) {
|
||||
state = state.difference({id});
|
||||
} else {
|
||||
state = state.union({id});
|
||||
}
|
||||
}
|
||||
|
||||
bool _isFavorite(int id) {
|
||||
return state.contains(id);
|
||||
}
|
||||
|
||||
Future<void> toggleFavorite(Asset asset) async {
|
||||
// TODO support local favorite assets
|
||||
if (asset.storage == AssetState.local) return;
|
||||
_setFavoriteForAssetId(asset.id, !_isFavorite(asset.id));
|
||||
|
||||
await assetNotifier.toggleFavorite(
|
||||
asset,
|
||||
state.contains(asset.id),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addToFavorites(Iterable<Asset> assets) {
|
||||
state = state.union(assets.map((a) => a.id).toSet());
|
||||
final futures = assets.map(
|
||||
(a) => assetNotifier.toggleFavorite(
|
||||
a,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return Future.wait(futures);
|
||||
}
|
||||
}
|
||||
|
||||
final favoriteProvider =
|
||||
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
|
||||
return FavoriteSelectionNotifier(
|
||||
ref.watch(assetProvider),
|
||||
ref.watch(assetProvider.notifier),
|
||||
);
|
||||
});
|
||||
|
||||
final favoriteAssetProvider = StateProvider((ref) {
|
||||
final favorites = ref.watch(favoriteProvider);
|
||||
|
||||
return ref
|
||||
.watch(assetProvider)
|
||||
.allAssets
|
||||
.where((element) => favorites.contains(element.id))
|
||||
.toList();
|
||||
});
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
|
||||
class FavoriteImage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final List<Asset> assets;
|
||||
|
||||
const FavoriteImage(this.asset, this.assets, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void viewAsset() {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
asset: asset,
|
||||
assetList: assets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: viewAsset,
|
||||
child: ImmichImage(
|
||||
asset,
|
||||
width: 300,
|
||||
height: 300,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
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:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class FavoritesPage extends HookConsumerWidget {
|
||||
const FavoritesPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selection = useState(<Asset>{});
|
||||
final processing = useState(false);
|
||||
|
||||
void selectionListener(
|
||||
bool multiselect,
|
||||
Set<Asset> selectedAssets,
|
||||
) {
|
||||
selectionEnabledHook.value = multiselect;
|
||||
selection.value = selectedAssets;
|
||||
}
|
||||
|
||||
AppBar buildAppBar() {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
@@ -24,11 +41,77 @@ class FavoritesPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void unfavorite() async {
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref.watch(assetProvider.notifier).toggleFavorite(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Removed ${selection.value.length} $assetOrAssets from favorites',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildBottomBar() {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
leading: const Icon(
|
||||
Icons.star_border,
|
||||
),
|
||||
title: const Text(
|
||||
"Unfavorite",
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
onTap: processing.value ? null : unfavorite,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
body: ImmichAssetGrid(
|
||||
assets: ref.watch(favoriteAssetProvider),
|
||||
),
|
||||
body: ref.watch(favoriteAssetsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) => Center(child: Text(error.toString())),
|
||||
data: (data) => data.isEmpty
|
||||
? Center(
|
||||
child: Text('favorites_page_no_favorites'.tr()),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
ImmichAssetGrid(
|
||||
renderList: data,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
listener: selectionListener,
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar()
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,212 +2,313 @@ import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final log = Logger('AssetGridDataStructure');
|
||||
|
||||
enum RenderAssetGridElementType {
|
||||
assets,
|
||||
assetRow,
|
||||
groupDividerTitle,
|
||||
monthTitle;
|
||||
}
|
||||
|
||||
class RenderAssetGridRow {
|
||||
final List<Asset> assets;
|
||||
final List<double> widthDistribution;
|
||||
|
||||
RenderAssetGridRow(this.assets, this.widthDistribution);
|
||||
}
|
||||
|
||||
class RenderAssetGridElement {
|
||||
final RenderAssetGridElementType type;
|
||||
final RenderAssetGridRow? assetRow;
|
||||
final String? title;
|
||||
final DateTime date;
|
||||
final List<Asset>? relatedAssetList;
|
||||
final int count;
|
||||
final int offset;
|
||||
final int totalCount;
|
||||
|
||||
RenderAssetGridElement(
|
||||
this.type, {
|
||||
this.assetRow,
|
||||
this.title,
|
||||
required this.date,
|
||||
this.relatedAssetList,
|
||||
this.count = 0,
|
||||
this.offset = 0,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
enum GroupAssetsBy {
|
||||
day,
|
||||
month;
|
||||
}
|
||||
|
||||
class AssetGridLayoutParameters {
|
||||
final int perRow;
|
||||
final bool dynamicLayout;
|
||||
final GroupAssetsBy groupBy;
|
||||
|
||||
AssetGridLayoutParameters(
|
||||
this.perRow,
|
||||
this.dynamicLayout,
|
||||
this.groupBy,
|
||||
);
|
||||
}
|
||||
|
||||
class _AssetGroupsToRenderListComputeParameters {
|
||||
final List<Asset> assets;
|
||||
final AssetGridLayoutParameters layout;
|
||||
|
||||
_AssetGroupsToRenderListComputeParameters(
|
||||
this.assets,
|
||||
this.layout,
|
||||
);
|
||||
month,
|
||||
auto,
|
||||
none,
|
||||
;
|
||||
}
|
||||
|
||||
class RenderList {
|
||||
final List<RenderAssetGridElement> elements;
|
||||
final List<Asset>? allAssets;
|
||||
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
|
||||
final int totalAssets;
|
||||
|
||||
RenderList(this.elements);
|
||||
/// reference to batch of assets loaded from DB with offset [_bufOffset]
|
||||
List<Asset> _buf = [];
|
||||
|
||||
static Map<DateTime, List<Asset>> _groupAssets(
|
||||
List<Asset> assets,
|
||||
GroupAssetsBy groupBy,
|
||||
) {
|
||||
if (groupBy == GroupAssetsBy.day) {
|
||||
return assets.groupListsBy(
|
||||
(element) {
|
||||
final date = element.fileCreatedAt.toLocal();
|
||||
return DateTime(date.year, date.month, date.day);
|
||||
},
|
||||
);
|
||||
} else if (groupBy == GroupAssetsBy.month) {
|
||||
return assets.groupListsBy(
|
||||
(element) {
|
||||
final date = element.fileCreatedAt.toLocal();
|
||||
return DateTime(date.year, date.month);
|
||||
},
|
||||
);
|
||||
/// global offset of assets in [_buf]
|
||||
int _bufOffset = 0;
|
||||
|
||||
RenderList(this.elements, this.query, this.allAssets)
|
||||
: totalAssets = allAssets?.length ?? query!.countSync();
|
||||
|
||||
bool get isEmpty => totalAssets == 0;
|
||||
|
||||
/// Loads the requested assets from the database to an internal buffer if not cached
|
||||
/// and returns a slice of that buffer
|
||||
List<Asset> loadAssets(int offset, int count) {
|
||||
assert(offset >= 0);
|
||||
assert(count > 0);
|
||||
assert(offset + count <= totalAssets);
|
||||
if (allAssets != null) {
|
||||
// if we already loaded all assets (e.g. from search result)
|
||||
// simply return the requested slice of that array
|
||||
return allAssets!.slice(offset, offset + count);
|
||||
} else if (query != null) {
|
||||
// general case: we have the query to load assets via offset from the DB on demand
|
||||
if (offset < _bufOffset || offset + count > _bufOffset + _buf.length) {
|
||||
// the requested slice (offset:offset+count) is not contained in the cache buffer `_buf`
|
||||
// thus, fill the buffer with a new batch of assets that at least contains the requested
|
||||
// assets and some more
|
||||
|
||||
final bool forward = _bufOffset < offset;
|
||||
// if the requested offset is greater than the cached offset, the user scrolls forward "down"
|
||||
const batchSize = 256;
|
||||
const oppositeSize = 64;
|
||||
|
||||
// make sure to load a meaningful amount of data (and not only the requested slice)
|
||||
// otherwise, each call to [loadAssets] would result in DB call trashing performance
|
||||
// fills small requests to [batchSize], adds some legroom into the opposite scroll direction for large requests
|
||||
final len = max(batchSize, count + oppositeSize);
|
||||
// when scrolling forward, start shortly before the requested offset...
|
||||
// when scrolling backward, end shortly after the requested offset...
|
||||
// ... to guard against the user scrolling in the other direction
|
||||
// a tiny bit resulting in a another required load from the DB
|
||||
final start = max(
|
||||
0,
|
||||
forward
|
||||
? offset - oppositeSize
|
||||
: (len > batchSize ? offset : offset + count - len),
|
||||
);
|
||||
// load the calculated batch (start:start+len) from the DB and put it into the buffer
|
||||
_buf = query!.offset(start).limit(len).findAllSync();
|
||||
_bufOffset = start;
|
||||
}
|
||||
assert(_bufOffset <= offset);
|
||||
assert(_bufOffset + _buf.length >= offset + count);
|
||||
// return the requested slice from the buffer (we made sure before that the assets are loaded!)
|
||||
return _buf.slice(offset - _bufOffset, offset - _bufOffset + count);
|
||||
}
|
||||
|
||||
return {};
|
||||
throw Exception("RenderList has neither assets nor query");
|
||||
}
|
||||
|
||||
static Future<RenderList> _processAssetGroupData(
|
||||
_AssetGroupsToRenderListComputeParameters data,
|
||||
/// Returns the requested asset either from cached buffer or directly from the database
|
||||
Asset loadAsset(int index) {
|
||||
if (allAssets != null) {
|
||||
// all assets are already loaded (e.g. from search result)
|
||||
return allAssets![index];
|
||||
} else if (query != null) {
|
||||
// general case: we have the DB query to load asset(s) on demand
|
||||
if (index >= _bufOffset && index < _bufOffset + _buf.length) {
|
||||
// lucky case: the requested asset is already cached in the buffer!
|
||||
return _buf[index - _bufOffset];
|
||||
}
|
||||
// request the asset from the database (not changing the buffer!)
|
||||
final asset = query!.offset(index).findFirstSync();
|
||||
if (asset == null) {
|
||||
throw Exception(
|
||||
"Asset at index $index does no longer exist in database",
|
||||
);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
throw Exception("RenderList has neither assets nor query");
|
||||
}
|
||||
|
||||
static Future<RenderList> fromQuery(
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> query,
|
||||
GroupAssetsBy groupBy,
|
||||
) =>
|
||||
_buildRenderList(null, query, groupBy);
|
||||
|
||||
static Future<RenderList> _buildRenderList(
|
||||
List<Asset>? assets,
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy>? query,
|
||||
GroupAssetsBy groupBy,
|
||||
) async {
|
||||
// TODO: Make DateFormat use the configured locale.
|
||||
final monthFormat = DateFormat.yMMM();
|
||||
final dayFormatSameYear = DateFormat.MMMEd();
|
||||
final dayFormatOtherYear = DateFormat.yMMMEd();
|
||||
final allAssets = data.assets;
|
||||
final perRow = data.layout.perRow;
|
||||
final dynamicLayout = data.layout.dynamicLayout;
|
||||
final groupBy = data.layout.groupBy;
|
||||
final List<RenderAssetGridElement> elements = [];
|
||||
|
||||
List<RenderAssetGridElement> elements = [];
|
||||
DateTime? lastDate;
|
||||
|
||||
final groups = _groupAssets(allAssets, groupBy);
|
||||
|
||||
groups.entries.sortedBy((e) => e.key).reversed.forEach((entry) {
|
||||
final date = entry.key;
|
||||
final assets = entry.value;
|
||||
|
||||
try {
|
||||
// Month title
|
||||
if (groupBy == GroupAssetsBy.day &&
|
||||
(lastDate == null || lastDate!.month != date.month)) {
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
title: monthFormat.format(date),
|
||||
date: date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Group divider title (day or month)
|
||||
var formatDate = dayFormatOtherYear;
|
||||
|
||||
if (DateTime.now().year == date.year) {
|
||||
formatDate = dayFormatSameYear;
|
||||
}
|
||||
|
||||
if (groupBy == GroupAssetsBy.month) {
|
||||
formatDate = monthFormat;
|
||||
}
|
||||
const pageSize = 500;
|
||||
const sectionSize = 60; // divides evenly by 2,3,4,5,6
|
||||
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
final int total = assets?.length ?? query!.countSync();
|
||||
for (int i = 0; i < total; i += sectionSize) {
|
||||
final date = assets != null
|
||||
? assets[i].fileCreatedAt
|
||||
: await query!.offset(i).fileCreatedAtProperty().findFirst();
|
||||
final int count = i + sectionSize > total ? total - i : sectionSize;
|
||||
if (date == null) break;
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.groupDividerTitle,
|
||||
title: formatDate.format(date),
|
||||
RenderAssetGridElementType.assets,
|
||||
date: date,
|
||||
relatedAssetList: assets,
|
||||
count: count,
|
||||
totalCount: total,
|
||||
offset: i,
|
||||
),
|
||||
);
|
||||
|
||||
// Add rows
|
||||
int cursor = 0;
|
||||
while (cursor < assets.length) {
|
||||
int rowElements = min(assets.length - cursor, perRow);
|
||||
final rowAssets = assets.sublist(cursor, cursor + rowElements);
|
||||
|
||||
// Default: All assets have the same width
|
||||
var widthDistribution = List.filled(rowElements, 1.0);
|
||||
|
||||
if (dynamicLayout) {
|
||||
final aspectRatios =
|
||||
rowAssets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / rowElements;
|
||||
|
||||
// 1: mean width
|
||||
// 0.5: width < mean - threshold
|
||||
// 1.5: width > mean + threshold
|
||||
final arConfiguration = aspectRatios.map((e) {
|
||||
if (e - meanAspectRatio > 0.3) return 1.5;
|
||||
if (e - meanAspectRatio < -0.3) return 0.5;
|
||||
return 1.0;
|
||||
});
|
||||
|
||||
// Normalize:
|
||||
final sum = arConfiguration.sum;
|
||||
widthDistribution =
|
||||
arConfiguration.map((e) => (e * rowElements) / sum).toList();
|
||||
}
|
||||
|
||||
final rowElement = RenderAssetGridElement(
|
||||
RenderAssetGridElementType.assetRow,
|
||||
date: date,
|
||||
assetRow: RenderAssetGridRow(
|
||||
rowAssets,
|
||||
widthDistribution,
|
||||
),
|
||||
);
|
||||
|
||||
elements.add(rowElement);
|
||||
cursor += rowElements;
|
||||
}
|
||||
|
||||
lastDate = date;
|
||||
} catch (e, stackTrace) {
|
||||
log.severe(e, stackTrace);
|
||||
}
|
||||
});
|
||||
return RenderList(elements, query, assets);
|
||||
}
|
||||
|
||||
return RenderList(elements);
|
||||
final formatSameYear =
|
||||
groupBy == GroupAssetsBy.month ? DateFormat.MMMM() : DateFormat.MMMEd();
|
||||
final formatOtherYear = groupBy == GroupAssetsBy.month
|
||||
? DateFormat.yMMMM()
|
||||
: DateFormat.yMMMEd();
|
||||
final currentYear = DateTime.now().year;
|
||||
final formatMergedSameYear = DateFormat.MMMd();
|
||||
final formatMergedOtherYear = DateFormat.yMMMd();
|
||||
|
||||
int offset = 0;
|
||||
DateTime? last;
|
||||
DateTime? current;
|
||||
int lastOffset = 0;
|
||||
int count = 0;
|
||||
int monthCount = 0;
|
||||
int lastMonthIndex = 0;
|
||||
|
||||
String formatDateRange(DateTime from, DateTime to) {
|
||||
final startDate = (from.year == currentYear
|
||||
? formatMergedSameYear
|
||||
: formatMergedOtherYear)
|
||||
.format(from);
|
||||
final endDate = (to.year == currentYear
|
||||
? formatMergedSameYear
|
||||
: formatMergedOtherYear)
|
||||
.format(to);
|
||||
if (DateTime(from.year, from.month, from.day) ==
|
||||
DateTime(to.year, to.month, to.day)) {
|
||||
// format range with time when both dates are on the same day
|
||||
final startTime = DateFormat.Hm().format(from);
|
||||
final endTime = DateFormat.Hm().format(to);
|
||||
return "$startDate $startTime - $endTime";
|
||||
}
|
||||
return "$startDate - $endDate";
|
||||
}
|
||||
|
||||
void mergeMonth() {
|
||||
if (last != null &&
|
||||
groupBy == GroupAssetsBy.auto &&
|
||||
monthCount <= 30 &&
|
||||
elements.length > lastMonthIndex + 1) {
|
||||
// merge all days into a single section
|
||||
assert(elements[lastMonthIndex].date.month == last.month);
|
||||
final e = elements[lastMonthIndex];
|
||||
|
||||
elements[lastMonthIndex] = RenderAssetGridElement(
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
date: e.date,
|
||||
count: monthCount,
|
||||
totalCount: monthCount,
|
||||
offset: e.offset,
|
||||
title: formatDateRange(e.date, elements.last.date),
|
||||
);
|
||||
elements.removeRange(lastMonthIndex + 1, elements.length);
|
||||
}
|
||||
}
|
||||
|
||||
void addElems(DateTime d, DateTime? prevDate) {
|
||||
final bool newMonth =
|
||||
last == null || last.year != d.year || last.month != d.month;
|
||||
if (newMonth) {
|
||||
mergeMonth();
|
||||
lastMonthIndex = elements.length;
|
||||
monthCount = 0;
|
||||
}
|
||||
for (int j = 0; j < count; j += sectionSize) {
|
||||
final type = j == 0
|
||||
? (groupBy != GroupAssetsBy.month && newMonth
|
||||
? RenderAssetGridElementType.monthTitle
|
||||
: RenderAssetGridElementType.groupDividerTitle)
|
||||
: (groupBy == GroupAssetsBy.auto
|
||||
? RenderAssetGridElementType.groupDividerTitle
|
||||
: RenderAssetGridElementType.assets);
|
||||
final sectionCount = j + sectionSize > count ? count - j : sectionSize;
|
||||
assert(sectionCount > 0 && sectionCount <= sectionSize);
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
type,
|
||||
date: d,
|
||||
count: sectionCount,
|
||||
totalCount: groupBy == GroupAssetsBy.auto ? sectionCount : count,
|
||||
offset: lastOffset + j,
|
||||
title: j == 0
|
||||
? (d.year == currentYear
|
||||
? formatSameYear.format(d)
|
||||
: formatOtherYear.format(d))
|
||||
: (groupBy == GroupAssetsBy.auto
|
||||
? formatDateRange(d, prevDate ?? d)
|
||||
: null),
|
||||
),
|
||||
);
|
||||
}
|
||||
monthCount += count;
|
||||
}
|
||||
|
||||
DateTime? prevDate;
|
||||
while (true) {
|
||||
// this iterates all assets (only their createdAt property) in batches
|
||||
// memory usage is okay, however runtime is linear with number of assets
|
||||
// TODO replace with groupBy once Isar supports such queries
|
||||
final dates = assets != null
|
||||
? assets.map((a) => a.fileCreatedAt)
|
||||
: await query!
|
||||
.offset(offset)
|
||||
.limit(pageSize)
|
||||
.fileCreatedAtProperty()
|
||||
.findAll();
|
||||
int i = 0;
|
||||
for (final date in dates) {
|
||||
final d = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
groupBy == GroupAssetsBy.month ? 1 : date.day,
|
||||
);
|
||||
current ??= d;
|
||||
if (current != d) {
|
||||
addElems(current, prevDate);
|
||||
last = current;
|
||||
current = d;
|
||||
lastOffset = offset + i;
|
||||
count = 0;
|
||||
}
|
||||
prevDate = date;
|
||||
count++;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (assets != null || dates.length != pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
if (count > 0 && current != null) {
|
||||
addElems(current, prevDate);
|
||||
mergeMonth();
|
||||
}
|
||||
assert(elements.every((e) => e.count <= sectionSize), "too large section");
|
||||
return RenderList(elements, query, assets);
|
||||
}
|
||||
|
||||
static RenderList empty() => RenderList([], null, []);
|
||||
|
||||
static Future<RenderList> fromAssets(
|
||||
List<Asset> assets,
|
||||
AssetGridLayoutParameters layout,
|
||||
) async {
|
||||
// Compute only allows for one parameter. Therefore we pass all parameters in a map
|
||||
return compute(
|
||||
_processAssetGroupData,
|
||||
_AssetGroupsToRenderListComputeParameters(
|
||||
assets,
|
||||
layout,
|
||||
),
|
||||
);
|
||||
}
|
||||
GroupAssetsBy groupBy,
|
||||
) =>
|
||||
_buildRenderList(assets, null, groupBy);
|
||||
}
|
||||
|
||||
@@ -396,8 +396,8 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
widget.scrollStateListener(true);
|
||||
|
||||
dragHaltTimer = Timer(
|
||||
const Duration(milliseconds: 200),
|
||||
() {
|
||||
const Duration(milliseconds: 500),
|
||||
() {
|
||||
widget.scrollStateListener(false);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,8 +19,6 @@ class GroupDividerTitle extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
|
||||
void handleTitleIconClick() {
|
||||
if (selected) {
|
||||
onDeselect();
|
||||
@@ -32,7 +30,7 @@ class GroupDividerTitle extends ConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 29.0,
|
||||
bottom: 29.0,
|
||||
bottom: 10.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final int? assetsPerRow;
|
||||
@@ -15,13 +16,19 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final bool? showStorageIndicator;
|
||||
final ImmichAssetGridSelectionListener? listener;
|
||||
final bool selectionActive;
|
||||
final List<Asset> assets;
|
||||
final List<Asset>? assets;
|
||||
final RenderList? renderList;
|
||||
final Future<void> Function()? onRefresh;
|
||||
final Set<Asset>? preselectedAssets;
|
||||
final bool canDeselect;
|
||||
final bool? dynamicLayout;
|
||||
final bool showMultiSelectIndicator;
|
||||
final void Function(ItemPosition start, ItemPosition end)?
|
||||
visibleItemsListener;
|
||||
|
||||
const ImmichAssetGrid({
|
||||
super.key,
|
||||
required this.assets,
|
||||
this.assets,
|
||||
this.onRefresh,
|
||||
this.renderList,
|
||||
this.assetsPerRow,
|
||||
@@ -29,12 +36,16 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
this.listener,
|
||||
this.margin = 5.0,
|
||||
this.selectionActive = false,
|
||||
this.preselectedAssets,
|
||||
this.canDeselect = true,
|
||||
this.dynamicLayout,
|
||||
this.showMultiSelectIndicator = true,
|
||||
this.visibleItemsListener,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
final renderListFuture = ref.watch(renderListProvider(assets));
|
||||
|
||||
// Needs to suppress hero animations when navigating to this widget
|
||||
final enableHeroAnimations = useState(false);
|
||||
@@ -64,34 +75,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (renderList != null) {
|
||||
Widget buildAssetGridView(RenderList renderList) {
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: HeroMode(
|
||||
enabled: enableHeroAnimations.value,
|
||||
child: ImmichAssetGridView(
|
||||
allAssets: assets,
|
||||
onRefresh: onRefresh,
|
||||
assetsPerRow: assetsPerRow ??
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
listener: listener,
|
||||
showStorageIndicator: showStorageIndicator ??
|
||||
settings.getSetting(AppSettingsEnum.storageIndicator),
|
||||
renderList: renderList!,
|
||||
margin: margin,
|
||||
selectionActive: selectionActive,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return renderListFuture.when(
|
||||
data: (renderList) => WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: HeroMode(
|
||||
enabled: enableHeroAnimations.value,
|
||||
child: ImmichAssetGridView(
|
||||
allAssets: assets,
|
||||
onRefresh: onRefresh,
|
||||
assetsPerRow: assetsPerRow ??
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
@@ -101,9 +90,22 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
renderList: renderList,
|
||||
margin: margin,
|
||||
selectionActive: selectionActive,
|
||||
preselectedAssets: preselectedAssets,
|
||||
canDeselect: canDeselect,
|
||||
dynamicLayout: dynamicLayout ??
|
||||
settings.getSetting(AppSettingsEnum.dynamicLayout),
|
||||
showMultiSelectIndicator: showMultiSelectIndicator,
|
||||
visibleItemsListener: visibleItemsListener,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (renderList != null) return buildAssetGridView(renderList!);
|
||||
|
||||
final renderListFuture = ref.watch(renderListProvider(assets!));
|
||||
return renderListFuture.when(
|
||||
data: (renderList) => buildAssetGridView(renderList),
|
||||
error: (err, stack) => Center(child: Text("$err")),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'asset_grid_data_structure.dart';
|
||||
import 'group_divider_title.dart';
|
||||
@@ -23,13 +25,11 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
ItemPositionsListener.create();
|
||||
|
||||
bool _scrolling = false;
|
||||
final Set<int> _selectedAssets = HashSet();
|
||||
final Set<Asset> _selectedAssets =
|
||||
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||
|
||||
Set<Asset> _getSelectedAssets() {
|
||||
return _selectedAssets
|
||||
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
|
||||
.whereNotNull()
|
||||
.toSet();
|
||||
return Set.from(_selectedAssets);
|
||||
}
|
||||
|
||||
void _callSelectionListener(bool selectionActive) {
|
||||
@@ -38,18 +38,14 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
|
||||
void _selectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.add(e.id);
|
||||
}
|
||||
_selectedAssets.addAll(assets);
|
||||
_callSelectionListener(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.remove(e.id);
|
||||
}
|
||||
_selectedAssets.removeAll(assets);
|
||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -57,64 +53,86 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
if (!widget.canDeselect &&
|
||||
widget.preselectedAssets != null &&
|
||||
widget.preselectedAssets!.isNotEmpty) {
|
||||
_selectedAssets.addAll(widget.preselectedAssets!);
|
||||
}
|
||||
_callSelectionListener(false);
|
||||
});
|
||||
|
||||
_callSelectionListener(false);
|
||||
}
|
||||
|
||||
bool _allAssetsSelected(List<Asset> assets) {
|
||||
return widget.selectionActive &&
|
||||
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
|
||||
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null;
|
||||
}
|
||||
|
||||
Widget _buildThumbnailOrPlaceholder(
|
||||
Asset asset,
|
||||
bool placeholder,
|
||||
) {
|
||||
if (placeholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
Widget _buildThumbnailOrPlaceholder(Asset asset, int index) {
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: widget.allAssets,
|
||||
index: index,
|
||||
loadAsset: widget.renderList.loadAsset,
|
||||
totalAssets: widget.renderList.totalAssets,
|
||||
multiselectEnabled: widget.selectionActive,
|
||||
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
|
||||
isSelected: widget.selectionActive && _selectedAssets.contains(asset),
|
||||
onSelect: () => _selectAssets([asset]),
|
||||
onDeselect: () => _deselectAssets([asset]),
|
||||
onDeselect: widget.canDeselect ||
|
||||
widget.preselectedAssets == null ||
|
||||
!widget.preselectedAssets!.contains(asset)
|
||||
? () => _deselectAssets([asset])
|
||||
: null,
|
||||
useGrayBoxPlaceholder: true,
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(
|
||||
Key key,
|
||||
BuildContext context,
|
||||
RenderAssetGridRow row,
|
||||
bool scrolling,
|
||||
List<Asset> assets,
|
||||
int absoluteOffset,
|
||||
double width,
|
||||
) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth / widget.assetsPerRow -
|
||||
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
|
||||
return Row(
|
||||
key: Key("asset-row-${row.assets.first.id}"),
|
||||
children: row.assets.mapIndexed((int index, Asset asset) {
|
||||
bool last = asset.id == row.assets.last.id;
|
||||
// Default: All assets have the same width
|
||||
final widthDistribution = List.filled(assets.length, 1.0);
|
||||
|
||||
return Container(
|
||||
key: Key("asset-${asset.id}"),
|
||||
width: size * row.widthDistribution[index],
|
||||
height: size,
|
||||
margin: EdgeInsets.only(
|
||||
top: widget.margin,
|
||||
right: last ? 0.0 : widget.margin,
|
||||
),
|
||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||
);
|
||||
}).toList(),
|
||||
if (widget.dynamicLayout) {
|
||||
final aspectRatios =
|
||||
assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / assets.length;
|
||||
|
||||
// 1: mean width
|
||||
// 0.5: width < mean - threshold
|
||||
// 1.5: width > mean + threshold
|
||||
final arConfiguration = aspectRatios.map((e) {
|
||||
if (e - meanAspectRatio > 0.3) return 1.5;
|
||||
if (e - meanAspectRatio < -0.3) return 0.5;
|
||||
return 1.0;
|
||||
});
|
||||
|
||||
// Normalize:
|
||||
final sum = arConfiguration.sum;
|
||||
widthDistribution.setRange(
|
||||
0,
|
||||
widthDistribution.length,
|
||||
arConfiguration.map((e) => (e * assets.length) / sum),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
key: key,
|
||||
children: assets.mapIndexed((int index, Asset asset) {
|
||||
final bool last = index + 1 == widget.assetsPerRow;
|
||||
return Container(
|
||||
key: ValueKey(index),
|
||||
width: width * widthDistribution[index],
|
||||
height: width,
|
||||
margin: EdgeInsets.only(
|
||||
top: widget.margin,
|
||||
right: last ? 0.0 : widget.margin,
|
||||
),
|
||||
child: _buildThumbnailOrPlaceholder(asset, absoluteOffset + index),
|
||||
);
|
||||
},
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,10 +150,14 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||
Widget _buildMonthTitle(BuildContext context, DateTime date) {
|
||||
final monthFormat = DateTime.now().year == date.year
|
||||
? DateFormat.MMMM()
|
||||
: DateFormat.yMMMM();
|
||||
final String title = monthFormat.format(date);
|
||||
return Padding(
|
||||
key: Key("month-$title"),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 30),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
@@ -147,18 +169,84 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceHolderRow(Key key, int num, double width, double height) {
|
||||
return Row(
|
||||
key: key,
|
||||
children: [
|
||||
for (int i = 0; i < num; i++)
|
||||
Container(
|
||||
key: ValueKey(i),
|
||||
width: width,
|
||||
height: height,
|
||||
margin: EdgeInsets.only(
|
||||
top: widget.margin,
|
||||
right: i + 1 == num ? 0.0 : widget.margin,
|
||||
),
|
||||
color: Colors.grey,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
BuildContext context,
|
||||
RenderAssetGridElement section,
|
||||
bool scrolling,
|
||||
) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth / widget.assetsPerRow -
|
||||
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
|
||||
final rows =
|
||||
(section.count + widget.assetsPerRow - 1) ~/ widget.assetsPerRow;
|
||||
final List<Asset> assetsToRender = scrolling
|
||||
? []
|
||||
: widget.renderList.loadAssets(section.offset, section.count);
|
||||
return Column(
|
||||
key: ValueKey(section.offset),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (section.type == RenderAssetGridElementType.monthTitle)
|
||||
_buildMonthTitle(context, section.date),
|
||||
if (section.type == RenderAssetGridElementType.groupDividerTitle ||
|
||||
section.type == RenderAssetGridElementType.monthTitle)
|
||||
_buildTitle(
|
||||
context,
|
||||
section.title!,
|
||||
scrolling
|
||||
? []
|
||||
: widget.renderList
|
||||
.loadAssets(section.offset, section.totalCount),
|
||||
),
|
||||
for (int i = 0; i < rows; i++)
|
||||
scrolling
|
||||
? _buildPlaceHolderRow(
|
||||
ValueKey(i),
|
||||
i + 1 == rows
|
||||
? section.count - i * widget.assetsPerRow
|
||||
: widget.assetsPerRow,
|
||||
width,
|
||||
width,
|
||||
)
|
||||
: _buildAssetRow(
|
||||
ValueKey(i),
|
||||
context,
|
||||
assetsToRender.nestedSlice(
|
||||
i * widget.assetsPerRow,
|
||||
min((i + 1) * widget.assetsPerRow, section.count),
|
||||
),
|
||||
section.offset + i * widget.assetsPerRow,
|
||||
width,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemBuilder(BuildContext c, int position) {
|
||||
final item = widget.renderList.elements[position];
|
||||
|
||||
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
|
||||
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||
return _buildMonthTitle(c, item.title!);
|
||||
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||
return _buildAssetRow(c, item.assetRow!, _scrolling);
|
||||
}
|
||||
|
||||
return const Text("Invalid widget type!");
|
||||
return _buildSection(c, item, _scrolling);
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
@@ -180,7 +268,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
}
|
||||
|
||||
Widget _buildAssetGrid() {
|
||||
final useDragScrolling = widget.allAssets.length >= 20;
|
||||
final useDragScrolling = widget.renderList.totalAssets >= 20;
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
setState(() {
|
||||
@@ -225,6 +313,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
} else if (widget.preselectedAssets != null) {
|
||||
setState(() {
|
||||
_selectedAssets.addAll(widget.preselectedAssets!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,14 +333,33 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||
if (widget.visibleItemsListener != null) {
|
||||
_itemPositionsListener.itemPositions.addListener(_positionListener);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollToTopNotifierProvider.removeListener(_scrollToTop);
|
||||
if (widget.visibleItemsListener != null) {
|
||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _positionListener() {
|
||||
final values = _itemPositionsListener.itemPositions.value;
|
||||
final start = values.firstOrNull;
|
||||
final end = values.lastOrNull;
|
||||
if (start != null && end != null) {
|
||||
if (start.index <= end.index) {
|
||||
widget.visibleItemsListener?.call(start, end);
|
||||
} else {
|
||||
widget.visibleItemsListener?.call(end, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToTop() {
|
||||
// for some reason, this is necessary as well in order
|
||||
// to correctly reposition the drag thumb scroll bar
|
||||
@@ -268,7 +379,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildAssetGrid(),
|
||||
if (widget.selectionActive) _buildMultiSelectIndicator(),
|
||||
if (widget.showMultiSelectIndicator && widget.selectionActive)
|
||||
_buildMultiSelectIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -282,19 +394,28 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||
final bool showStorageIndicator;
|
||||
final ImmichAssetGridSelectionListener? listener;
|
||||
final bool selectionActive;
|
||||
final List<Asset> allAssets;
|
||||
final Future<void> Function()? onRefresh;
|
||||
final Set<Asset>? preselectedAssets;
|
||||
final bool canDeselect;
|
||||
final bool dynamicLayout;
|
||||
final bool showMultiSelectIndicator;
|
||||
final void Function(ItemPosition start, ItemPosition end)?
|
||||
visibleItemsListener;
|
||||
|
||||
const ImmichAssetGridView({
|
||||
super.key,
|
||||
required this.renderList,
|
||||
required this.allAssets,
|
||||
required this.assetsPerRow,
|
||||
required this.showStorageIndicator,
|
||||
this.listener,
|
||||
this.margin = 5.0,
|
||||
this.selectionActive = false,
|
||||
this.onRefresh,
|
||||
this.preselectedAssets,
|
||||
this.canDeselect = true,
|
||||
this.dynamicLayout = true,
|
||||
this.showMultiSelectIndicator = true,
|
||||
this.visibleItemsListener,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
@@ -10,7 +9,9 @@ import 'package:immich_mobile/utils/storage_indicator.dart';
|
||||
|
||||
class ThumbnailImage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final List<Asset> assetList;
|
||||
final int index;
|
||||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final bool showStorageIndicator;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
final bool isSelected;
|
||||
@@ -21,7 +22,9 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
const ThumbnailImage({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
required this.index,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
this.showStorageIndicator = true,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
this.isSelected = false,
|
||||
@@ -57,8 +60,9 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
} else {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
assetList: assetList,
|
||||
asset: asset,
|
||||
initialIndex: index,
|
||||
loadAsset: loadAsset,
|
||||
totalAssets: totalAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -100,7 +104,9 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
border: multiselectEnabled && isSelected
|
||||
? Border.all(
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
color: onDeselect == null
|
||||
? Colors.grey
|
||||
: Theme.of(context).primaryColorLight,
|
||||
width: 10,
|
||||
)
|
||||
: const Border(),
|
||||
@@ -130,12 +136,12 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
if (ref.watch(favoriteProvider).contains(asset.id))
|
||||
if (asset.isFavorite)
|
||||
const Positioned(
|
||||
left: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
Icons.star,
|
||||
Icons.favorite,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
|
||||
@@ -7,15 +7,16 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
class ControlBottomAppBar extends ConsumerWidget {
|
||||
final Function onShare;
|
||||
final Function onFavorite;
|
||||
final Function onArchive;
|
||||
final Function onDelete;
|
||||
final void Function() onShare;
|
||||
final void Function() onFavorite;
|
||||
final void Function() onArchive;
|
||||
final void Function() onDelete;
|
||||
final Function(Album album) onAddToAlbum;
|
||||
final void Function() onCreateNewAlbum;
|
||||
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
final bool enabled;
|
||||
|
||||
const ControlBottomAppBar({
|
||||
Key? key,
|
||||
@@ -27,6 +28,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
required this.albums,
|
||||
required this.onAddToAlbum,
|
||||
required this.onCreateNewAlbum,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -39,35 +41,31 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
ControlBoxButton(
|
||||
iconData: Icons.ios_share_rounded,
|
||||
label: "control_bottom_app_bar_share".tr(),
|
||||
onPressed: () {
|
||||
onShare();
|
||||
},
|
||||
onPressed: enabled ? onShare : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.star_rounded,
|
||||
iconData: Icons.favorite_border_rounded,
|
||||
label: "control_bottom_app_bar_favorite".tr(),
|
||||
onPressed: () {
|
||||
onFavorite();
|
||||
},
|
||||
onPressed: enabled ? onFavorite : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.delete_outline_rounded,
|
||||
label: "control_bottom_app_bar_delete".tr(),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onPressed: enabled
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.archive,
|
||||
label: "control_bottom_app_bar_archive".tr(),
|
||||
onPressed: () => onArchive(),
|
||||
onPressed: enabled ? onArchive : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -108,7 +106,9 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
),
|
||||
AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum),
|
||||
AddToAlbumTitleRow(
|
||||
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -118,6 +118,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onAddToAlbum: onAddToAlbum,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
@@ -137,7 +138,7 @@ class AddToAlbumTitleRow extends StatelessWidget {
|
||||
required this.onCreateNewAlbum,
|
||||
});
|
||||
|
||||
final VoidCallback onCreateNewAlbum;
|
||||
final VoidCallback? onCreateNewAlbum;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user